From 1024588471874765af0c3377781e44afbd06f2bd Mon Sep 17 00:00:00 2001 From: empty610 Date: Sat, 31 Oct 2026 12:40:08 +0800 Subject: [PATCH 1/2] Update public source to 0.6.0 --- .gitignore | 5 ++ AGENTS.md | 5 ++ CHANGELOG.md | 14 +++ Enable-Autostart.ps1 | 13 ++- README.md | 16 ++-- desktop/runtime.cjs | 22 +++-- desktop/watch-codex.ps1 | 8 +- docs/task-models.md | 2 + docs/usage.md | 8 +- package-lock.json | 4 +- package.json | 2 +- plugin.json | 2 +- plugin/lifecycle.js | 7 +- plugin/shutdown.js | 22 +++-- plugin/stop-owned.ps1 | 55 ++++++++++-- public/app.js | 12 ++- public/messages.js | 12 +++ public/settings-form.js | 38 ++++++++ public/task-forms.js | 3 +- public/task-view.js | 2 +- public/widget.js | 10 ++- scripts/source-files.js | 1 + server/health.js | 3 +- server/http-routes.js | 2 +- server/manager.js | 8 +- server/quota-wake.js | 2 +- server/static-assets.js | 2 +- server/widget-state.cjs | 19 ++++ tests/autostart-data.test.js | 38 ++++++++ tests/fixtures/autostart-host.ps1 | 17 ++++ tests/fixtures/review4-ui.cjs | 102 ++++++++++++++++++++++ tests/fixtures/shutdown-owned-service.cjs | 3 +- tests/fixtures/watcher-host.ps1 | 18 ++++ tests/fixtures/widget-instance.cjs | 38 ++++++++ tests/fixtures/widget-view.js | 3 +- tests/import-http.test.js | 9 ++ tests/import.test.js | 25 ++++++ tests/monitor-policy.test.js | 1 + tests/public-privacy.test.js | 5 ++ tests/quota-wake.test.js | 22 +++++ tests/reliability.test.js | 7 +- tests/review4-ui.test.js | 22 +++++ tests/runtime-lifecycle.test.js | 19 +++- tests/settings-recovery.test.js | 62 +++++++++++++ tests/shared-ui.test.js | 15 +++- tests/shutdown.test.js | 57 +++++++++++- tests/watcher-data.test.js | 29 ++++++ tests/widget-instance.test.js | 57 ++++++++++++ tests/widget-smoke.test.js | 8 +- 49 files changed, 777 insertions(+), 79 deletions(-) create mode 100644 public/settings-form.js create mode 100644 server/widget-state.cjs create mode 100644 tests/autostart-data.test.js create mode 100644 tests/fixtures/autostart-host.ps1 create mode 100644 tests/fixtures/review4-ui.cjs create mode 100644 tests/fixtures/watcher-host.ps1 create mode 100644 tests/fixtures/widget-instance.cjs create mode 100644 tests/review4-ui.test.js create mode 100644 tests/settings-recovery.test.js create mode 100644 tests/watcher-data.test.js create mode 100644 tests/widget-instance.test.js diff --git a/.gitignore b/.gitignore index 326f5ae..6c8e990 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ tls-*.json *.pfx *.p12 /source-manifest.json + +# Local assessment and review materials stay outside commits. +.review*/ +[Aa][Ss][Ss][Ee][Ss][Ss][Mm][Ee][Nn][Tt]*/ +*[Aa][Ss][Ss][Ee][Ss][Ss][Mm][Ee][Nn][Tt]*.md diff --git a/AGENTS.md b/AGENTS.md index 5b11717..075c01e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,10 @@ # 本对话的版本约定 +- 用户于 2026-10-31 更新约定:当前及后续版本固定为 `0.6.0`,本地开发分支为 `codex/0.6.0`;不要自动递增或添加 preview 序号。下方旧版本约定仅作历史记录。 +- 用户于 2026-10-31 明确授权本轮将最新源码及用户 README 同步到本地脱敏发布仓库后推送并合入公开 `main`。通过独立公开仓库和正常 PR 检查完成,不推送私有开发历史;此次不安装或发布 Release,之后默认仍只保存本地。 +- assessment 和临时 `.review` 审查材料统一保存在被忽略的 `.cache/assessments/`;不要加入 Git 暂存区、提交或公开源码包。用户明确仅移走现有 `.review` 审查目录,`docs` 中的项目评估等正式文档保留。使用明确文件列表暂存,提交前检查列表。 +- 当前版本需要构建时使用独立的 `0.6.0` 源码导出目录,保留旧版构建和安装。 + - 用户于 2026-10-29 更新约定:后续更改统一记录为 `0.5.1`,在 `preview/0.5.1` 分支开发;不要自动递增版本号或添加 preview 序号。此前 `0.5.0-preview` 已推送并保留。 - 用户于 2026-10-29 本轮明确授权:将 `0.5.1` 源码和用户编写的 README 原样推送并合入公开仓库 `main`;仍通过脱敏独立仓库发布。该次授权不包含安装、切换正在使用的插件或发布 Release;完成后后续改动默认仍只保存在本地,除非收到新指令。 - 当前继续使用的 `0.4.0-preview.1` 是保留版本。不要覆盖其安装目录、已生成的 0.4.0 构建、运行配置、任务数据或 GitHub `main` 分支。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9e6bc..670b460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ # 更新记录 +## 0.6.0 — 2026-10-31 + +- 汇总 0.5.1 发布后的数据目录、首次额度监测、演示窗口隔离、备份恢复及表单修复。 +- 「加入我的工作」可选择加入后立即开始监测,默认关闭。 +- 退出前记录所属服务进程树,退出后清理残留子进程;核对进程创建时间,避免 PID 复用误杀。 +- 当前版本统一记录为 0.6.0;用户编写的 README 原样保留。 + ## 0.5.1 — 开发中 +- 第四轮审查修复:PowerShell 回退监听器显式传递指定数据目录并恢复调用方环境;发送结果不确定时清除旧等待原因,卡片按实际阻塞状态提示人工处理;完整页面保留并按顺序补充自定义轮询周期选项。 +- 「加入我的工作」新增「加入后开始监测」选项,默认关闭;勾选时与聊天记录一并保存,启用此聊天的监测,继续遵循后台调度开关、额度耗尽恢复和发送上限。中英文挂件与完整页面均支持;MCP 导入仍保持暂停。 +- 第四轮修复与新增选项验证:369 项回归全部通过,包含真实 DOM 的自定义周期、语言切换草稿和监测选项提交、PowerShell 目录传递、HTTP 持久化及不确定发送状态;语法和公开源码检查通过,未安装或发送真实消息。 +- 第三轮审查修复:正式与演示模式使用独立的 Electron 配置、挂件偏好和诊断文件;启动及第二实例请求核对模式、服务地址和数据目录,避免演示启动打开正式窗口。 +- 自启动启用和停用共用运行时的数据目录解析,避免源码目录因创建 data 文件夹而切换默认数据位置,兼容中文路径。 +- 恢复备份后同步设置表单;完整页面和挂件保留正在编辑的字段、同步未编辑字段,保存时只提交用户修改项,避免覆盖恢复后的执行上限。 +- 第三轮修复验证:362 项回归全部通过,包含双向启动顺序的原生窗口锁检查、10 组自启动目录组合及两种界面的备份恢复检查;语法和公开源码检查通过。测试使用独立数据和模拟通道。 - 后续版本统一记录为 0.5.1,在 preview/0.5.1 分支开发。 - main 合入检查修正:状态文件的大小检查与读取共用同一文件描述符;HTML 转义回归补充大写及带属性的 SCRIPT 输入,并使用不区分大小写的断言。 - 插件、命令行和桌面共用启动与退出接口;本机配置、固定证书请求和生命周期协调明确分工,统一服务构建及目录核验,保留查询冷启动与完整退出规则。 diff --git a/Enable-Autostart.ps1 b/Enable-Autostart.ps1 index 71439f4..06e993e 100644 --- a/Enable-Autostart.ps1 +++ b/Enable-Autostart.ps1 @@ -5,14 +5,11 @@ $continuerName = 'CodexContinuer' $continuerNode = (Get-Command node -ErrorAction Stop).Source $continuerRoot = [IO.Path]::GetFullPath($PluginRoot) $continuerWatcher = Join-Path $continuerRoot 'desktop/watch-codex.ps1' -$continuerRuntime = Join-Path $continuerRoot 'runtime.json' -$continuerData = Join-Path $continuerRoot 'data' -if (Test-Path -LiteralPath $continuerRuntime) { - $continuerConfig = Get-Content -LiteralPath $continuerRuntime -Raw | ConvertFrom-Json - if ($continuerConfig.dataDir) { $continuerData = $continuerConfig.dataDir } - elseif ($continuerConfig.profile -eq 'portable' -or -not (Test-Path -LiteralPath $continuerData)) { $continuerData = Join-Path $env:LOCALAPPDATA 'CodexContinuer' } -} -if ($env:CONTINUER_DATA_DIR) { $continuerData = $env:CONTINUER_DATA_DIR } +# Resolve before creating directories, using the same rules as every runtime. +# Base64 preserves Unicode paths across Windows PowerShell console encodings. +$continuerEncodedData = & $continuerNode '-e' 'process.stdout.write(Buffer.from(require(process.argv[1]).dataDir, ''utf16le'').toString(''base64''))' (Join-Path $continuerRoot 'server/config.cjs') +if ($LASTEXITCODE -ne 0 -or -not $continuerEncodedData) { throw 'Could not resolve the Continuer data directory.' } +$continuerData = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($continuerEncodedData)) New-Item -ItemType Directory -Path $continuerData -Force | Out-Null $continuerRegistryKey = Get-Item -LiteralPath $continuerRegistry -ErrorAction SilentlyContinue $continuerExisting = if ($continuerRegistryKey) { $continuerRegistryKey.GetValue($continuerName, $null) } else { $null } diff --git a/README.md b/README.md index 3a8743c..6effc97 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 监测 Codex 账户额度,在额度恢复后接续指定工作,并通过跟随 Codex 窗口的桌面挂件查看状态。 -**开发版本:0.5.1 · Windows x64 预览版 · GPL-3.0-only。** +**开发版本:0.6.0 · Windows x64 预览版 · GPL-3.0-only。** ## 功能 @@ -22,13 +22,13 @@ ## 相对上版本的改动 -- 你现在可以随意编辑它在额度耗尽后的发送内容 -- 解决了即使当前任务已经结束,仍然会对被设置监测的对话发送约定消息的问题 -- 拆分了事件处理器的职责,减轻了核心管理器manager.js的负担 -- 明确了不同工作的边界,加入了跨模式校验 -- 减轻了重复逻辑的使用,稍微优化了石山 -- 给依赖文字和源码形状的实现添加了新的协议 -- 给英文版装了新的依赖 +- 处理了你更新版本后,挂件与已安装的插件使用不同数据目录导致连接失败的问题 +- 解决了首次使用插件或首次对对话执行调度后,可能永久等待额度的问题 +- 处理了演示模式复用正式窗口的问题 +- 解决了退出continuer后因后台继续而导致重启很麻烦的问题 +- 现在在设置工作和选择对话处你可以直接开始监测 + + ## 安装 diff --git a/desktop/runtime.cjs b/desktop/runtime.cjs index c1139be..b40ee1e 100644 --- a/desktop/runtime.cjs +++ b/desktop/runtime.cjs @@ -11,13 +11,17 @@ function createDesktopRuntime({ load = require, processInfo = process, timing = const runtimeConfig = load('../server/config.cjs'); const logging = load('../server/logging.cjs'); const localTls = load('../server/local-tls.cjs'); + const { widgetState, matchesWidgetIdentity } = load('../server/widget-state.cjs'); app.setName('Codex Continuer'); const dataDir = runtimeConfig.dataDir; + const demo = processInfo.env.CONTINUER_DEMO === '1'; + const widget = widgetState(dataDir, runtimeConfig.port, demo); + const widgetInfo = () => ({ ...runtimeConfig.appInfo(), ...widget.identity }); mkdirSync(dataDir, { recursive: true }); logging.installLogs(dataDir, 'widget'); - app.setPath('userData', join(dataDir, 'desktop-profile')); - const preferenceFile = join(dataDir, 'widget-preferences.json'); + app.setPath('userData', widget.profile); + const preferenceFile = widget.preferences; let prefs = { follow: true, expanded: false, hidden: false, compactSize: 256, offsets: {}, positions: {} }; try { prefs = { ...prefs, ...JSON.parse(readFileSync(preferenceFile, 'utf8')) }; } catch {} let preferenceError = null; @@ -34,22 +38,22 @@ function createDesktopRuntime({ load = require, processInfo = process, timing = if (processInfo.argv.includes('--auto')) prefs.hidden = false; function diagnostics() { if (!win || win.isDestroyed() || dragging || pointerDrag?.active) return; - const next = JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: isReady, visible: win.isVisible(), ...state(), bounds: win.getBounds() }); - if (next !== lastDiagnostic) { lastDiagnostic = next; try { writeFileSync(join(dataDir, 'widget-status.json'), next); } catch (error) { console.error(`挂件诊断未保存:${error.message}`); } } + const next = JSON.stringify({ ...widgetInfo(), pid: processInfo.pid, ready: isReady, visible: win.isVisible(), ...state(), bounds: win.getBounds() }); + if (next !== lastDiagnostic) { lastDiagnostic = next; try { writeFileSync(widget.status, next); } catch (error) { console.error(`挂件诊断未保存:${error.message}`); } } } - const ownsInstance = app.requestSingleInstanceLock(); + const ownsInstance = app.requestSingleInstanceLock(widget.identity); let quitting, shutdownComplete = false; if (!ownsInstance) { app.quit(); } else if (processInfo.argv.includes('--quit')) { app.whenReady().then(quitAll); } else { - app.on('second-instance', (_event, argv) => { if (argv.includes('--quit')) { void quitAll(); return; } if (closing) return; prefs.hidden = false; if (!argv.includes('--auto')) changeExpanded(true); save(); show(!argv.includes('--auto')); }); + app.on('second-instance', (_event, argv, _directory, identity) => { if (!matchesWidgetIdentity(identity, widget.identity, processInfo.platform)) return; if (argv.includes('--quit')) { void quitAll(); return; } if (closing) return; prefs.hidden = false; if (!argv.includes('--auto')) changeExpanded(true); save(); show(!argv.includes('--auto')); }); app.whenReady().then(start).catch(error => { dialog.showErrorBox(nativeText("native.continuer_could_not_start"), error.message); app.quit(); }); } function quitAll() { if (quitting) return quitting; closing = true; isReady = false; pointerDrag?.finish(); cancelTransition(); tracker?.kill(); win?.hide(); - try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: false, exitReason: 'quitting', updatedAt: Date.now() })); } catch {} + try { writeFileSync(widget.status, JSON.stringify({ ...widgetInfo(), pid: processInfo.pid, ready: false, exitReason: 'quitting', updatedAt: Date.now() })); } catch {} quitting = (async () => { try { const runtime = await import('../plugin/runtime.js'); @@ -250,7 +254,7 @@ function createDesktopRuntime({ load = require, processInfo = process, timing = let rendererRestarts = 0; win.webContents.on('render-process-gone', (_event, detail) => { pointerDrag.finish(); isReady = false; lastDiagnostic = null; lastSentState = null; - try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: false, exitReason: `renderer-${detail.reason}` })); } catch {} + try { writeFileSync(widget.status, JSON.stringify({ ...widgetInfo(), pid: processInfo.pid, ready: false, exitReason: `renderer-${detail.reason}` })); } catch {} if (!closing && rendererRestarts++ < 3) setTimeout(() => { if (!closing && !win.isDestroyed()) win.reload(); }, 1000 * rendererRestarts); }); win.webContents.on('did-finish-load', () => { if (isReady) show(); }); @@ -317,7 +321,7 @@ function createDesktopRuntime({ load = require, processInfo = process, timing = if (!ownsInstance) return; if (!shutdownComplete) { event.preventDefault(); void quitAll(); return; } closing = true; pointerDrag?.finish(); cancelTransition(); tracker?.kill(); globalShortcut.unregisterAll(); tray?.destroy(); - try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: false, exitReason: 'user-quit', updatedAt: Date.now() })); } catch {} + try { writeFileSync(widget.status, JSON.stringify({ ...widgetInfo(), pid: processInfo.pid, ready: false, exitReason: 'user-quit', updatedAt: Date.now() })); } catch {} }); app.on('window-all-closed', () => app.quit()); return { changeExpanded, finishTransition, show, savePosition, state, setFollow, get preferences() { return prefs; } }; diff --git a/desktop/watch-codex.ps1 b/desktop/watch-codex.ps1 index b0e4695..2017fee 100644 --- a/desktop/watch-codex.ps1 +++ b/desktop/watch-codex.ps1 @@ -30,8 +30,12 @@ try { if ($continuerSeen) { $continuerSeen = ''; Write-ContinuerStartupStatus 'watching' 'Codex desktop closed; waiting for next launch' } } elseif ($continuerKey -ne $continuerSeen -and [DateTime]::UtcNow -ge $continuerRetryAt) { # Run inside this already-hidden console; LASTEXITCODE is reliable in Windows PowerShell 5.1. - & $NodePath $continuerLaunchPath '--auto' 1> (Join-Path $DataDir 'autostart-launch.log') 2> (Join-Path $DataDir 'autostart-error.log') - if ($LASTEXITCODE -ne 0) { throw 'Widget startup failed; see autostart-error.log' } + $continuerPreviousData = $env:CONTINUER_DATA_DIR + try { + $env:CONTINUER_DATA_DIR = $DataDir + & $NodePath $continuerLaunchPath '--auto' 1> (Join-Path $DataDir 'autostart-launch.log') 2> (Join-Path $DataDir 'autostart-error.log') + if ($LASTEXITCODE -ne 0) { throw 'Widget startup failed; see autostart-error.log' } + } finally { $env:CONTINUER_DATA_DIR = $continuerPreviousData } $continuerSeen = $continuerKey Write-ContinuerStartupStatus 'launched' 'Widget started for Codex desktop' } diff --git a/docs/task-models.md b/docs/task-models.md index 7353620..40afa1c 100644 --- a/docs/task-models.md +++ b/docs/task-models.md @@ -18,6 +18,8 @@ 两种模式仍使用现有的 `continuous` 字段以兼容界面和持久化格式,含义分别是“允许继续下一轮”和“开启额度监测”。状态及计数的规则由各自模块维护,调用方不再通过同一段分支处理两类任务的暂停、继续和完成。 +通过挂件或完整页面加入原聊天时,可勾选「加入后开始监测」,与新记录一并保存;未勾选和 MCP 导入都保持暂停。开启监测不会改变后台调度开关,也不会因当前额度正常而立即发送消息,仍需确认后续耗尽与恢复。 + ## 旧数据与恢复 - 读取没有 `mode` 的旧工作记录时补为 `direct`,保留目标和执行次数。明确标为 `direct` 的 writer 等待继续按工作模式重试。 diff --git a/docs/usage.md b/docs/usage.md index afc6a82..ce2d1c8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,6 +1,6 @@ # 功能与使用细节 -**0.5.1 · Windows x64 预览版。** 开源准备、验证证据与剩余验收见 [开源准备记录](open-source-readiness.md)。使用范围见 [兼容性说明](compatibility.md),参与开发和反馈见 [CONTRIBUTING.md](../CONTRIBUTING.md)、[SECURITY.md](../SECURITY.md)。本工具独立于 OpenAI、Rhine 和 Clash Verge,不代表这些项目官方产品。 +**0.6.0 · Windows x64 预览版。** 开源准备、验证证据与剩余验收见 [开源准备记录](open-source-readiness.md)。使用范围见 [兼容性说明](compatibility.md),参与开发和反馈见 [CONTRIBUTING.md](../CONTRIBUTING.md)、[SECURITY.md](../SECURITY.md)。本工具独立于 OpenAI、Rhine 和 Clash Verge,不代表这些项目官方产品。 一个可运行的本地 Codex 插件:监测账户额度、查看任务计划,并在额度恢复后继续指定工作。v0.3.3 基于 [Rhine 仓库的动画 SVG](https://github.com/empty610/rhine_ui_clash-verge/blob/a298150dbbd13a217bf48fe574f8fd27bfec2b80/src/assets/image/rhine-signal-loop.svg),保留 60 秒的轮廓、飞行、行星、轨道形变动画。本地版本精简重复关键帧与叠加滤镜,将 48 个粒子动画合并为 3 组,并移除方形外框;不使用图片序列缓存或额外合成层。界面使用仓库中的暖灰、琥珀配色,支持明暗主题。 @@ -55,7 +55,7 @@ cd .\codex-continuer ### 直接选择对话 -「我的工作」会自动读取本机 Codex 对话,按最近更新时间排列,可刷新、加载更多,并记住选中项。选择后点击「加入我的工作」,可填写仅本地保存的监测备注。已有项目目录自动读取,加入后保持暂停。当前桌面页面的前台对话 ID 未通过这里使用的接口公开,因此需要从列表选择一次,不将最近更新时间当作当前页面身份。 +「我的工作」会自动读取本机 Codex 对话,按最近更新时间排列,可刷新、加载更多,并记住选中项。选择后点击「加入我的工作」,可填写仅本地保存的监测备注,并选择「加入后开始监测」。该选项默认不勾选,加入后保持暂停;勾选则在加入时启用此聊天的额度监测。后台调度已暂停时,仍需开启调度。已有项目目录自动读取。当前桌面页面的前台对话 ID 未通过这里使用的接口公开,因此需要从列表选择一次,不将最近更新时间当作当前页面身份。 同一聊天同时只允许一个导入操作;挂件和 MCP 共用这项保护。重复请求会提示已添加或正在添加,不会创建第二条记录,也不会重置已有监测。读取失败后可以重新导入,不同聊天可以同时添加。 @@ -108,7 +108,7 @@ CLI 已登录时不必重复登录。普通 Windows 用户环境即可运行, ## 使用 1. 新建工作:填写名称、现有项目的绝对目录、目标和验收标准。创建后即排队执行。 -2. 或导入已有本地 Codex 会话:点击「加入我的工作」后,可填写续作消息和监测备注。续作消息留空(含仅空白)时,根据发送时的界面语言发送中文「继续」或英文「Continue」;填写时每次恢复都发送原文。备注保存到首页工作卡片,仅本地展示,不发送。导入后保持暂停;开启额度监测并确认耗尽、恢复后才发送。详情可查看完整备注和实际续作消息。 +2. 或导入已有本地 Codex 会话:点击「加入我的工作」后,可填写续作消息和监测备注,并选择是否「加入后开始监测」。续作消息留空(含仅空白)时,根据发送时的界面语言发送中文「继续」或英文「Continue」;填写时每次恢复都发送原文。备注保存到首页工作卡片,仅本地展示,不发送。未勾选时加入后暂停;勾选后启用监测,仍需后台调度开启并确认额度耗尽、恢复后才发送,加入本身不会立即发送消息。详情可查看完整备注和实际续作消息。MCP 导入仍保持暂停。 挂件与完整页面顶部的语言选择器可切换「中文 / English」。设置保存到本地,重新打开后保留,并同步到其他窗口;旧数据默认中文。语言切换不会开启自动调度、清空输入或翻译工作名称、备注和自定义续作消息。 @@ -162,7 +162,7 @@ node scripts/probe.js `scripts/probe.js` 只读额度并检查会话列表,不发送模型请求。已在本机账号验证额度与会话读取;自动调度通过协议替身测试与浏览器演示验收,未对用户真实项目启动续作测试。 -`node scripts/smoke-plugin.js <插件安装目录>` 检查实际插件包的 MCP 握手、工具发现与只读状态查询。`data/widget-status.json` 记录原生挂件进程、就绪状态和是否检测到 Codex 窗口,便于诊断;它不包含聊天内容。 +`node scripts/smoke-plugin.js <插件安装目录>` 检查实际插件包的 MCP 握手、工具发现与只读状态查询。`data/widget-status.json` 记录正式挂件进程、就绪状态和是否检测到 Codex 窗口,便于诊断;它不包含聊天内容。演示模式使用 `demo-widget-status.json`,并单独保存 `demo-widget-preferences.json` 和 `demo-desktop-profile`,可与正式窗口同时打开和分别退出。 ## 数据与部署 diff --git a/package-lock.json b/package-lock.json index 84ec807..aac648f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-continuer", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-continuer", - "version": "0.5.1", + "version": "0.6.0", "devDependencies": { "electron": "45.0.0" }, diff --git a/package.json b/package.json index ecfe77c..2c0c106 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-continuer", - "version": "0.5.1", + "version": "0.6.0", "private": true, "type": "module", "description": "Local Codex quota monitor and resumable task manager", diff --git a/plugin.json b/plugin.json index b35a6bb..1bb96b6 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "codex-continuer", - "version": "0.5.1", + "version": "0.6.0", "description": "Windows 预览版:Codex 额度监测、原聊天接续与任务管理", "extensions": { "com.openai": { diff --git a/plugin/lifecycle.js b/plugin/lifecycle.js index 439511f..b24da6e 100644 --- a/plugin/lifecycle.js +++ b/plugin/lifecycle.js @@ -2,6 +2,8 @@ import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { assertServiceIdentity, runtimeError, samePath, serviceUnavailable } from './local-client.js'; import { message, messageFields } from '../public/message.js'; +import widgetStateModule from '../server/widget-state.cjs'; +const { widgetState, matchesWidgetIdentity } = widgetStateModule; // The lifecycle knows process roles and readiness. Host I/O is supplied by the // runtime adapter; constructing it never reads state or starts a process. @@ -9,6 +11,7 @@ export function createLifecycle({ root, dataDir, port, demo = false, platform = appInfo, request, launch, exists, diagnostic, alive, quit, shutdown, wait = delay, now = () => performance.now(), serviceTimeoutMs = 18000, widgetAttempts = 60 }) { let starting, opening, stopping, generation = 0; + const widgetFiles = widgetState(dataDir, port, demo); const assertOpen = (expected = generation) => { if (expected !== generation || stopping || quit.requested()) throw runtimeError('EXIT_REQUESTED', 'runtime.exited'); }; @@ -86,8 +89,8 @@ export function createLifecycle({ root, dataDir, port, demo = false, platform = try { for (let attempt = 0; attempt < widgetAttempts; attempt++) { assertOpen(expected); if (spawnError) throw spawnError; - const widget = diagnostic(join(dataDir, 'widget-status.json')); - if (widget?.ready && widget.buildId === appInfo().buildId && alive(widget.pid)) { + const widget = diagnostic(widgetFiles.status); + if (widget?.ready && widget.buildId === appInfo().buildId && matchesWidgetIdentity(widget, widgetFiles.identity, platform) && alive(widget.pid)) { return { launched: true, ready: true, mode: widget.follow ? 'follow-codex' : 'independent', ...messageFields(message('runtime.widget_ready')) }; } await wait(150); diff --git a/plugin/shutdown.js b/plugin/shutdown.js index 1d41010..cfb7543 100644 --- a/plugin/shutdown.js +++ b/plugin/shutdown.js @@ -1,30 +1,40 @@ import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import exitState from '../server/exit-state.cjs'; import { alive, diagnostic } from '../server/health.js'; import { assertServiceIdentity } from './local-client.js'; -const execute = promisify(execFile); -export async function stopBackground({ root, dataDir, request, demo = false, cleanup = async (phase, pid = 0) => { - await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'plugin/stop-owned.ps1'), '-PluginRoot', root, '-DataDir', dataDir, '-Phase', phase, '-ServicePid', String(pid)], { windowsHide: true, timeout: 10000 }); +export async function stopBackground({ root, dataDir, request, demo = false, cleanup = async (phase, pid = 0, snapshot = []) => { + const stdout = await new Promise((resolve, reject) => { + const child = execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'plugin/stop-owned.ps1'), '-PluginRoot', root, '-DataDir', dataDir, '-Phase', phase, '-ServicePid', String(pid), ...(phase === 'service' ? ['-ReadSnapshot'] : [])], { windowsHide: true, timeout: 10000 }, (error, stdout) => error ? reject(error) : resolve(stdout)); + child.stdin.on('error', () => {}); + child.stdin.end(phase === 'service' ? JSON.stringify(snapshot) : ''); + }); + if (phase === 'capture') return JSON.parse(stdout); }, isAlive = alive, wait = delay, graceMs = 2500 }) { // Explicit reopening clears this latch; passive MCP reads and autostart do not. exitState.mark(dataDir, demo); const errors = []; if (!demo) try { await cleanup('watcher'); } catch (error) { errors.push(error.message); } let pid = diagnostic(join(dataDir, demo ? 'demo-service.lock' : 'service.lock'))?.pid; + let verified = false, snapshot = []; try { const state = await request('/api/state', undefined, { timeoutMs: 2000 }); assertServiceIdentity(state, { root, dataDir }); pid = state.diagnostics.pid; - await request('/api/shutdown', {}, { timeoutMs: 5000 }); + verified = true; } catch (error) { if (error.cause?.code !== 'ECONNREFUSED') console.error('正常停止未完成,将核验本应用进程后结束:' + error.message); } + // Preserve process identities before graceful shutdown can orphan descendants. + if (Number.isInteger(pid) && pid > 0) { + try { snapshot = await cleanup('capture', pid) || []; } catch (error) { errors.push(error.message); } + } + if (verified) try { await request('/api/shutdown', {}, { timeoutMs: 5000 }); } + catch (error) { console.error('正常停止未完成,将核验本应用进程后结束:' + error.message); } for (let elapsed = 0; Number.isInteger(pid) && pid > 0 && isAlive(pid) && elapsed < graceMs; elapsed += 100) await wait(100); - try { await cleanup('service', Number.isInteger(pid) && pid > 0 ? pid : 0); } catch (error) { errors.push(error.message); } + try { await cleanup('service', Number.isInteger(pid) && pid > 0 ? pid : 0, snapshot); } catch (error) { errors.push(error.message); } if (Number.isInteger(pid) && pid > 0 && isAlive(pid)) errors.push('Continuer service is still running.'); if (errors.length) throw Error(errors.join('\n')); } diff --git a/plugin/stop-owned.ps1 b/plugin/stop-owned.ps1 index 4077b44..31c386c 100644 --- a/plugin/stop-owned.ps1 +++ b/plugin/stop-owned.ps1 @@ -1,5 +1,7 @@ -param([Parameter(Mandatory=$true)][string]$PluginRoot, [Parameter(Mandatory=$true)][string]$DataDir, [ValidateSet('watcher','service')][string]$Phase, [int]$ServicePid = 0) +param([Parameter(Mandatory=$true)][string]$PluginRoot, [Parameter(Mandatory=$true)][string]$DataDir, [ValidateSet('watcher','capture','service')][string]$Phase, [int]$ServicePid = 0, [switch]$ReadSnapshot) $ErrorActionPreference = 'Stop' +[Console]::InputEncoding = [Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) $continuerRoot = [IO.Path]::GetFullPath($PluginRoot).TrimEnd('\') function Test-ContinuerCommand($process, [string]$file) { return $process -and $process.CommandLine -and $process.CommandLine.Replace('/', '\').IndexOf($file.Replace('/', '\'), [StringComparison]::OrdinalIgnoreCase) -ge 0 @@ -9,7 +11,7 @@ if ($Phase -eq 'watcher') { if (-not (Test-Path -LiteralPath $continuerStatusFile)) { exit 0 } $continuerSaved = Get-Content -LiteralPath $continuerStatusFile -Raw | ConvertFrom-Json if (-not $continuerSaved.pid -or -not $continuerSaved.pluginRoot -or [IO.Path]::GetFullPath($continuerSaved.pluginRoot).TrimEnd('\') -ne $continuerRoot) { exit 0 } - $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$([int]$continuerSaved.pid)" -ErrorAction SilentlyContinue + $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$([int]$continuerSaved.pid)" $continuerNative = $continuerProcess -and $continuerProcess.ExecutablePath -eq (Join-Path $continuerRoot 'desktop/continuer-monitor.exe') -and $continuerProcess.CommandLine -match '(?:^|\s)"?--watch"?(?:\s|$)' $continuerLegacy = $continuerProcess -and $continuerProcess.Name -eq 'powershell.exe' -and (Test-ContinuerCommand $continuerProcess (Join-Path $continuerRoot 'desktop/watch-codex.ps1')) if ($continuerNative -or $continuerLegacy) { @@ -19,11 +21,52 @@ if ($Phase -eq 'watcher') { } exit 0 } +function Assert-ContinuerService($process) { + if ($process.Name -notmatch '^(node|electron)\.exe$' -or -not (Test-ContinuerCommand $process (Join-Path $continuerRoot 'server/index.js'))) { throw 'Refusing to stop a process outside the selected Continuer installation.' } +} +function Get-ContinuerIdentity($process) { + return [pscustomobject]@{ pid = [int]$process.ProcessId; created = $process.CreationDate.ToUniversalTime().Ticks.ToString(); executable = $process.ExecutablePath; command = $process.CommandLine } +} +function Stop-ContinuerTree([int]$processId) { + & (Join-Path $env:SystemRoot 'System32/taskkill.exe') /PID $processId /T /F 2>$null | Out-Null + if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $processId -ErrorAction SilentlyContinue)) { throw 'Could not terminate the selected Continuer process tree.' } +} +if ($Phase -eq 'capture') { + $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$ServicePid" + if (-not $continuerProcess) { Write-Output '[]'; exit 0 } + Assert-ContinuerService $continuerProcess + $continuerProcesses = @(Get-CimInstance Win32_Process) + $continuerTree = [Collections.Generic.List[object]]::new() + $continuerTree.Add($continuerProcess) + for ($index = 0; $index -lt $continuerTree.Count; $index++) { + $parent = $continuerTree[$index] + foreach ($child in $continuerProcesses) { + if ($child.ParentProcessId -eq $parent.ProcessId -and $child.CreationDate -ge $parent.CreationDate -and $child.ProcessId -notin $continuerTree.ProcessId) { $continuerTree.Add($child) } + } + } + ConvertTo-Json -InputObject @($continuerTree | ForEach-Object { Get-ContinuerIdentity $_ }) -Compress + exit 0 +} if ($Phase -eq 'service' -and $ServicePid -gt 0) { - $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$ServicePid" -ErrorAction SilentlyContinue + $continuerSnapshot = @() + if ($ReadSnapshot) { $continuerSnapshot = ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd()) } + if ($continuerSnapshot.Count -gt 0) { + if ($continuerSnapshot[0].pid -ne $ServicePid) { throw 'Service snapshot identity mismatch.' } + $continuerErrors = @() + foreach ($saved in $continuerSnapshot) { + $current = Get-CimInstance Win32_Process -Filter "ProcessId=$([int]$saved.pid)" + if (-not $current) { continue } + $identity = Get-ContinuerIdentity $current + # A recycled PID must never target another application, even if its path matches. + if ($identity.created -cne $saved.created -or $identity.executable -cne $saved.executable -or $identity.command -cne $saved.command) { continue } + try { Stop-ContinuerTree $identity.pid } catch { $continuerErrors += $_.Exception.Message } + } + if ($continuerErrors.Count) { throw ($continuerErrors -join "`n") } + exit 0 + } + $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$ServicePid" if (-not $continuerProcess) { exit 0 } - if ($continuerProcess.Name -notmatch '^(node|electron)\.exe$' -or -not (Test-ContinuerCommand $continuerProcess (Join-Path $continuerRoot 'server/index.js'))) { throw 'Refusing to stop a process outside the selected Continuer installation.' } + Assert-ContinuerService $continuerProcess # The root PID was verified above; terminate only its remaining process tree. - & (Join-Path $env:SystemRoot 'System32/taskkill.exe') /PID $ServicePid /T /F | Out-Null - if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $ServicePid -ErrorAction SilentlyContinue)) { throw 'Could not terminate the selected Continuer service.' } + Stop-ContinuerTree $ServicePid } diff --git a/public/app.js b/public/app.js index b16d749..2900e40 100644 --- a/public/app.js +++ b/public/app.js @@ -9,12 +9,14 @@ import { VisibleFeed, setHtml } from './view-lifecycle.js'; import { ApprovalDetails } from './approval-details.js'; import { storageNotice } from './storage-status.js'; import { wakeExplanation, continuationEditor } from './task-mode.js'; +import { SettingsForm } from './settings-form.js'; export function mountPage(environment = globalThis) { const { document, window, localStorage, Date, FormData, AbortSignal, DOMParser, getComputedStyle, setTimeout, clearTimeout, setInterval, clearInterval, fetch, Blob, URL } = environment; const api = createApiClient({ request: (...args) => fetch(...args) }); const applyStaticLanguage = staticInterface(document); const $ = selector => document.querySelector(selector); + const settingsForm = new SettingsForm(() => $('#settings-form')); document.querySelectorAll('[data-icon]').forEach(el => { el.innerHTML = icon(el.dataset.icon); }); const labels = () => ({ overview: tr("ui.overview"), tasks: tr("ui.work"), quota: tr("ui.quota_monitor"), logs: tr("ui.activity_log"), settings: tr("ui.settings") }); const taskDetails = new TaskDetails(api, () => { if (!document.hidden && selectedTask) renderDetail(selectedTask); }, error => toast(systemText(error))); @@ -124,6 +126,7 @@ ${esc(a.cwd)}${a.requiresDetails ? `

${tr("ui.full_approval_details_are_ $('#footer-update').textContent = tr("ui.every_sec", { p0: state.settings.pollSeconds, p1: state.sampledAt ? tr("ui.updated_at", { p0: time(state.sampledAt) }) : tr("ui.quota_data_unknown") }); // Preserve form input while background events arrive. if (!languageChanged && page === 'settings' && $('#settings-form')) { + settingsForm.sync(state.settings); const control = $('#settings-form .switch'); control.classList.toggle('on', state.settings.enabled); control.setAttribute('aria-checked', String(state.settings.enabled)); @@ -136,7 +139,8 @@ ${esc(a.cwd)}${a.requiresDetails ? `

${tr("ui.full_approval_details_are_ else if (page === 'logs') html = `${heading(tr("ui.activity_log"), tr("ui.shows_the_latest_events_export_up_to"), ``)}

${state.logs.length ? `${state.logs.map(l => ``).join('')}
${tr("ui.time")}${tr("ui.type")}${tr("ui.activity")}
${new Date(l.time).toLocaleDateString(locale())} ${time(l.time)}${({ success: tr("ui.success"), warning: tr("ui.attention"), info: tr("ui.info") })[l.type]}${esc(systemText(l))}
` : empty(tr("ui.no_activity_yet"), tr("ui.scheduling_activity_appears_here_after_you_add"))}
`; else html = settings(); setHtml(main, html); - if (draft) for (const [name, value] of draft) { const input = $('#settings-form').elements.namedItem(name); if (input) input.value = value; } + if (draft) settingsForm.restore(draft); + if (page === 'settings') settingsForm.sync(state.settings); if (modal.open && selectedTask) renderDetail(selectedTask); } function openModal(html) { modalRenderer = null; importList = null; selectedTask = null; $('#modal-body').innerHTML = html; if (!modal.open) modal.showModal(); } @@ -199,7 +203,7 @@ ${esc(a.cwd)}${a.requiresDetails ? `

${tr("ui.full_approval_details_are_ } if (action === 'export') { const logs = await api('/api/logs'); const blob = new Blob([JSON.stringify(logs.logs, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'continuer-logs.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return; } button.disabled = true; - if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); toast(tr("ui.storage_recovered_scheduling_remains_off")); } + if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); if (action === 'storage-backup') settingsForm.sync(state.settings, { reset: true }); toast(tr("ui.storage_recovered_scheduling_remains_off")); } else if (action === 'refresh') { state = await api('/api/refresh', {}); toast(state.connection.status === 'connected' ? tr("ui.quota_synced") : tr("ui.connection_failed_check_the_notice")); } else if (action === 'scheduler') { state = await api('/api/settings', { enabled: !state.settings.enabled }); toast(state.settings.enabled ? tr("ui.scheduling_enabled") : tr("ui.scheduling_paused")); } else if (action === 'continuous') { state = await api(`/api/tasks/${id}`, { action, value: !state.tasks.find(t => t.id === id).continuous }); } @@ -215,9 +219,9 @@ ${esc(a.cwd)}${a.requiresDetails ? `

${tr("ui.full_approval_details_are_ const submit = form.querySelector('[type="submit"],button.primary'); submit.disabled = true; try { if (form.id === 'task-form') { state = await api('/api/tasks', { ...values, continuous: values.continuous === 'on' }); modal.close(); toast(tr("ui.work_created")); } - else if (form.id === 'import-form') { state = await api('/api/import', { ...values, threadId: form.dataset.id }); modal.close(); toast(tr("ui.chat_imported_monitoring_stays_paused")); } + else if (form.id === 'import-form') { const startMonitoring = values.startMonitoring === 'on'; state = await api('/api/import', { ...values, startMonitoring, threadId: form.dataset.id }); modal.close(); toast(tr(startMonitoring ? "ui.chat_added_monitoring_enabled" : "ui.chat_imported_monitoring_stays_paused")); } else if (form.id === 'message-form') { state = await api(`/api/tasks/${encodeURIComponent(form.dataset.id)}`, { action: 'message', value: values.continuationMessage }); taskDetails.clear(); modal.close(); toast(tr("ui.continuation_message_saved")); } - else if (form.id === 'settings-form') { state = await api('/api/settings', Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Number(v)]))); document.activeElement?.blur(); toast(tr("ui.settings_saved")); } + else if (form.id === 'settings-form') { state = await api('/api/settings', settingsForm.changes(values)); document.activeElement?.blur(); toast(tr("ui.settings_saved")); } render(); } catch (error) { const errorEl = form.querySelector('.form-error'); if (errorEl) setFormError(errorEl, error); else toast(systemText(error)); } finally { submit.disabled = false; } diff --git a/public/messages.js b/public/messages.js index 2812804..b245e9a 100644 --- a/public/messages.js +++ b/public/messages.js @@ -1,5 +1,17 @@ // Stable IDs own both translations; literal source text is never a lookup key. export const messages = Object.freeze({ + "ui.start_monitoring_after_adding": { + "zh": "加入后开始监测", + "en": "Start monitoring after adding" + }, + "ui.import_monitoring_choice_hint": { + "zh": "不勾选则保持暂停。后台调度暂停时,请先开启调度。", + "en": "Leave unchecked to add paused. If background scheduling is paused, enable it to begin monitoring. " + }, + "ui.chat_added_monitoring_enabled": { + "zh": "已加入工作并开启监测", + "en": "Chat added with monitoring enabled" + }, "ui.request_failed": { "zh": "请求失败", "en": "Request failed" diff --git a/public/settings-form.js b/public/settings-form.js new file mode 100644 index 0000000..5fe78b6 --- /dev/null +++ b/public/settings-form.js @@ -0,0 +1,38 @@ +import { t as tr } from './i18n.js'; +const fields = ['minimumRemaining', 'pollSeconds', 'maxRounds', 'maxWakeSends']; + +function ensureOption(input, key, value) { + if (input.tagName !== 'SELECT' || !value || [...input.options].some(option => option.value === value)) return; + const option = input.ownerDocument.createElement('option'); + option.value = value; option.textContent = key === 'pollSeconds' ? `${value}${tr('ui.sec')}` : value; + input.add(option, [...input.options].find(existing => Number(existing.value) > Number(value)) || null); +} + +// Keep unedited fields current without replacing a user's in-progress edits. +export class SettingsForm { + constructor(getForm) { this.getForm = getForm; this.baseline = new Map(); } + sync(settings, { reset = false } = {}) { + if (reset) this.baseline.clear(); + const form = this.getForm(); + if (!form) return; + for (const key of fields) { + const input = form.elements.namedItem(key); + if (!input) continue; + const value = String(settings[key] ?? ''); + ensureOption(input, key, value); + if (!this.baseline.has(key) || String(input.value) === this.baseline.get(key) || String(input.value) === value) input.value = value; + this.baseline.set(key, value); + } + } + restore(draft) { + const form = this.getForm(); + if (!form) return; + for (const [key, value] of draft) { + const input = fields.includes(key) && form.elements.namedItem(key); + if (input) { ensureOption(input, key, String(value)); input.value = value; } + } + } + changes(values) { + return Object.fromEntries(fields.filter(key => key in values && String(values[key]) !== this.baseline.get(key)).map(key => [key, Number(values[key])])); + } +} diff --git a/public/task-forms.js b/public/task-forms.js index ef15c6b..3ee0090 100644 --- a/public/task-forms.js +++ b/public/task-forms.js @@ -49,7 +49,8 @@ export function monitorImportForm(threadId, { layout = 'page', thread = null } = -

${thread ? tr("ui.monitoring_stays_paused_after_adding") : tr("ui.monitoring_stays_paused_after_import")}${esc(wakeExplanation)}

+ +

${tr("ui.import_monitoring_choice_hint")}${esc(wakeExplanation)}

${actions} `; } diff --git a/public/task-view.js b/public/task-view.js index 054c5ce..80771fd 100644 --- a/public/task-view.js +++ b/public/task-view.js @@ -23,7 +23,7 @@ export const isTaskActive = task => activeStatuses.has(task.status); export function taskStatusLabel(task) { if (task.status === 'monitoring') return tr("ui.monitoring"); - if (task.waitReason === 'desktop') return tr("ui.waiting_for_delivery"); + if (task.status === 'waiting' && task.waitReason === 'desktop') return tr("ui.waiting_for_delivery"); if (task.status === 'waiting' && ['writer', 'thread'].includes(task.waitReason)) return tr("ui.waiting_for_chat"); return statusText[task.status] ? tr(statusText[task.status]) : task.status || ''; } diff --git a/public/widget.js b/public/widget.js index 029097c..0cc57c5 100644 --- a/public/widget.js +++ b/public/widget.js @@ -10,19 +10,21 @@ import { storageNotice } from './storage-status.js'; import { wakeExplanation, continuationEditor } from './task-mode.js'; import { quotaWindow, resetCountdown } from './quota-display.js'; import { pixelText } from './rhine-readout.js'; +import { SettingsForm } from './settings-form.js'; export function mountWidget(environment = globalThis) { const { document, window, localStorage, Date, FormData, AbortSignal, DOMParser, getComputedStyle, setTimeout, clearTimeout, setInterval, clearInterval, fetch, Blob, URL } = environment; const api = createApiClient({ request: (...args) => fetch(...args) }); const applyStaticLanguage = staticInterface(document); const $ = s => document.querySelector(s); + const settingsForm = new SettingsForm(() => $('#settings-form')); const bridge = window.continuerDesktop; const QUOTA_CYCLE_MS = 12000, QUOTA_TRANSITION_MS = 1000; const taskDetails = new TaskDetails(api, () => { if (viewVisible() && detailId && $('#modal').open) detail(detailId); }, error => toast(systemText(error))); const approvalDetails = new ApprovalDetails(api, () => render()); let lastDesktopLanguage, desktopPreferenceError = null, rendererAnnounced = false, rendererReady = false; let modalRenderer = null, sessionModal = null; - let state, connected = true, settingsInitialized = false, lastPixelCountdown, timer, detailId; + let state, connected = true, lastPixelCountdown, timer, detailId; let sessionItems = [], sessionCursor = null, sessionLoading = false, sessionError = null, lastSessionRead = 0, lastSessionAttempt = 0; let windowSlot='primary', carouselTimer, swapTimer, refreshTimer, gaugeTimer, panelTimer, nativeVisible=!bridge, compactPixels=256; const reducedMotion=window.matchMedia('(prefers-reduced-motion: reduce)'); @@ -157,6 +159,7 @@ export function mountWidget(environment = globalThis) { void bridge.control('language', value).catch(error => { lastDesktopLanguage = undefined; toast(systemText(error)); }); } renderGauge(); + settingsForm.sync(state.settings); if(!viewVisible() || document.body.classList.contains('collapsed')) { syncActivity(); return; } const buckets = Object.values(state.quota?.rateLimitsByLimitId || {}).filter(Boolean); if (!buckets.length && state.quota?.rateLimits) buckets.push(state.quota.rateLimits); @@ -174,7 +177,6 @@ export function mountWidget(environment = globalThis) { setHtml($('#approvals'), approvalDetails.readAll(state.approvals).map(a => `
${tr("ui.approval_required")}

${esc(a.reason)}

${esc(a.command || a.method)}
 ${esc(a.cwd)}
${a.requiresDetails ? `

${tr("ui.full_approval_details_are_not_loaded_refresh_2")}

` : ''}
`).join('')); setHtml($('#logs'), state.logs.slice(0, 10).map(l => `
${esc(systemText(l))}
`).join('') || `

${tr("ui.no_activity_yet_2")}

`); - if (!settingsInitialized) { for (const key of ['minimumRemaining', 'pollSeconds', 'maxRounds', 'maxWakeSends']) $(`#settings-form [name=${key}]`).value = state.settings[key]; settingsInitialized = true; } refreshSessionsIfNeeded(); renderSessionOptions(); syncActivity(); @@ -260,14 +262,14 @@ ${esc(a.cwd)}${a.requiresDetails ? `

${tr("ui.full_approval_details_are_ return; } b.disabled = true; - if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); toast(tr("ui.storage_recovered_scheduling_remains_off")); } + if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); if (action === 'storage-backup') settingsForm.sync(state.settings, { reset: true }); toast(tr("ui.storage_recovered_scheduling_remains_off")); } else if (action === 'refresh') { state = await api('/api/refresh', {}); toast(tr("ui.quota_sync_requested")); } else if (['pause', 'resume', 'complete'].includes(action)) state = await api(`/api/tasks/${encodeURIComponent(id)}`, { action }); else if (action === 'approve' || action === 'decline') { if (action === 'approve' && !approvalDetails.ready(state.approvals.find(item => String(item.id) === id && item.detailToken === token))) throw Error(tr("ui.wait_for_the_full_approval_details_to")); state = await api('/api/approval', { id, token, decision: action === 'approve' ? 'accept' : 'decline' }); } render(); } catch (error) { if ($('#modal').open) setFormError($('#error'), error); else toast(systemText(error)); } finally { b.disabled = false; } }); document.addEventListener('change', async e => { const input = e.target; try { if (input.id === 'language') { input.disabled = true; try { state = await api('/api/settings', { language: input.value }); } finally { input.disabled = false; } } else if (input.id === 'scheduler') state = await api('/api/settings', { enabled: input.checked }); else if (input.dataset.continuous) state = await api(`/api/tasks/${encodeURIComponent(input.dataset.continuous)}`, { action: 'continuous', value: input.checked }); else if (input.id === 'follow') { applyDesktop(await bridge.control('follow', input.checked)); return; } else return; render(); } catch (error) { toast(systemText(error)); render(); } }); - document.addEventListener('submit', async e => { e.preventDefault(); const form = e.target, values = Object.fromEntries(new FormData(form)), button = form.querySelector('[type=submit]'); button.disabled = true; setFormError($('#error'), ''); try { if (form.id === 'new-form') state = await api('/api/tasks', { ...values, continuous: values.continuous === 'on' }); else if (form.id === 'import-form') state = await api('/api/import', { threadId: form.dataset.id, note: values.note, continuationMessage: values.continuationMessage }); else if (form.id === 'message-form') { state = await api(`/api/tasks/${encodeURIComponent(form.dataset.id)}`, { action: 'message', value: values.continuationMessage }); taskDetails.clear(); } else if (form.id === 'settings-form') state = await api('/api/settings', Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Number(v)]))); if (form.id !== 'settings-form') $('#modal').close(); render(); toast(form.id === 'message-form' ? tr("ui.continuation_message_saved") : tr("ui.saved")); } catch (error) { if ($('#modal').open) setFormError($('#error'), error); else toast(systemText(error)); } finally { button.disabled = false; } }); + document.addEventListener('submit', async e => { e.preventDefault(); const form = e.target, values = Object.fromEntries(new FormData(form)), button = form.querySelector('[type=submit]'); button.disabled = true; setFormError($('#error'), ''); try { if (form.id === 'new-form') state = await api('/api/tasks', { ...values, continuous: values.continuous === 'on' }); else if (form.id === 'import-form') state = await api('/api/import', { threadId: form.dataset.id, note: values.note, continuationMessage: values.continuationMessage, startMonitoring: values.startMonitoring === 'on' }); else if (form.id === 'message-form') { state = await api(`/api/tasks/${encodeURIComponent(form.dataset.id)}`, { action: 'message', value: values.continuationMessage }); taskDetails.clear(); } else if (form.id === 'settings-form') state = await api('/api/settings', settingsForm.changes(values)); if (form.id !== 'settings-form') $('#modal').close(); render(); toast(form.id === 'message-form' ? tr("ui.continuation_message_saved") : tr("ui.saved")); } catch (error) { if ($('#modal').open) setFormError($('#error'), error); else toast(systemText(error)); } finally { button.disabled = false; } }); function setSize(value) { compactPixels=Math.max(160,Math.min(420,Math.round(Number(value)||256))); document.documentElement.style.setProperty('--compact-size',`${compactPixels}px`); diff --git a/scripts/source-files.js b/scripts/source-files.js index 1850699..afb74ce 100644 --- a/scripts/source-files.js +++ b/scripts/source-files.js @@ -7,6 +7,7 @@ export function publicFiles(root = '.') { const visit = directory => { for (const item of readdirSync(join(root, directory), { withFileTypes: true })) { const name = directory + '/' + item.name; + if (/assessment|^\.review/i.test(item.name)) continue; if (item.isSymbolicLink()) throw Error('Public source must not contain symlinks: ' + name); if (item.isDirectory()) visit(name); else if (item.isFile() && !name.endsWith('.exe')) files.push(name); diff --git a/server/health.js b/server/health.js index 70f01aa..0ff9230 100644 --- a/server/health.js +++ b/server/health.js @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import config from './config.cjs'; +import widgetStateModule from './widget-state.cjs'; export function diagnostic(file) { try { if (statSync(file).size > 65536) return null; return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return null; } @@ -18,7 +19,7 @@ export class Health { try { writeFileSync(this.file, JSON.stringify({ ...config.appInfo(), pid: process.pid, status, reason, startedAt: this.startedAt, updatedAt: Date.now() }), { mode: 0o600 }); } catch {} } snapshot() { - const widget = diagnostic(join(this.dataDir, 'widget-status.json')); + const widget = diagnostic(widgetStateModule.widgetState(this.dataDir, config.port, this.manager.demo).status); const watcher = diagnostic(join(this.dataDir, 'autostart-status.json')); const preferences = diagnostic(join(this.dataDir, 'autostart-preferences.json')); return { ...config.appInfo(), pid: process.pid, startedAt: this.startedAt, uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), diff --git a/server/http-routes.js b/server/http-routes.js index 99f07a6..e0bae73 100644 --- a/server/http-routes.js +++ b/server/http-routes.js @@ -16,7 +16,7 @@ export function createApiRouter({ manager, health, stream, rpc, demo, summary, s ['/api/tasks', input => manager.create(input)], ['/api/import', input => { if (input.note !== undefined && input.prompt !== undefined) throw new ClientError(textMessage("error.note_alias_conflict")); - return manager.importThread(input.threadId, input.note ?? input.prompt, input.continuationMessage); + return manager.importThread(input.threadId, input.note ?? input.prompt, input.continuationMessage, { startMonitoring: input.startMonitoring }); }], ['/api/approval', input => manager.approve(input.id, input.decision, input.token)], ]); diff --git a/server/manager.js b/server/manager.js index e8542b5..aade90a 100644 --- a/server/manager.js +++ b/server/manager.js @@ -244,7 +244,8 @@ export class Manager extends EventEmitter { if (input.enabled === true) this.activate(); } async listThreads(cursor = null) { await this.rpc.connect?.(); return this.rpc.call('thread/list', { limit: 30, sortKey: 'updated_at', cursor }); } - async importThread(id, note = '', continuationMessage = '') { + async importThread(id, note = '', continuationMessage = '', { startMonitoring = false } = {}) { + if (typeof startMonitoring !== 'boolean') throw new ClientError(textMessage("error.invalid_switch_value")); if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError(textMessage("error.this_chat_has_already_been_added")); if (this.importingThreads.has(id)) throw new ClientError(textMessage("error.this_chat_is_being_added_do_not_import")); if (this.state.tasks.length >= 100) throw new ClientError(textMessage("error.up_to_records_are_supported_end_unused_work")); @@ -257,7 +258,10 @@ export class Manager extends EventEmitter { if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError(textMessage("error.this_chat_has_already_been_added")); if (this.state.tasks.length >= 100) throw new ClientError(textMessage("error.up_to_records_are_supported_end_unused_work")); const task = createMonitorTask(id, thread, note, continuationMessage); - this.state.tasks.unshift(task); this.log(textMessage("log.chat_added", { name: task.name })); this.changed(); return task; + if (startMonitoring) taskModel(task).action(task, 'resume', undefined, { sending: false, maxWakeSends: this.state.settings.maxWakeSends }); + this.state.tasks.unshift(task); this.log(textMessage("log.chat_added", { name: task.name })); this.changed(); + if (startMonitoring) this.activate(); + return task; } finally { this.importingThreads.delete(id); } } } diff --git a/server/quota-wake.js b/server/quota-wake.js index 966c41b..31429da 100644 --- a/server/quota-wake.js +++ b/server/quota-wake.js @@ -69,7 +69,7 @@ export async function tickQuotaWake(manager, epoch = manager.scheduleEpoch) { } else if (error.cancelled) { if (wake.delivery === 'sending') wake.delivery = 'pending'; } else if (error.safeToRetry && task.continuous && task.status !== 'paused') { wake.delivery = 'pending'; task.waitReason = 'desktop'; task.retryAt = Date.now() + 60000; - } else { wake.delivery = 'uncertain'; if (task.status !== 'paused') task.status = 'blocked'; } + } else { wake.delivery = 'uncertain'; task.waitReason = null; task.retryAt = 0; if (task.status !== 'paused') task.status = 'blocked'; } setActivity(task, error); manager.log(taskActivityMessage(task), error.replyComplete ? 'info' : 'warning', task.id); } diff --git a/server/static-assets.js b/server/static-assets.js index 4a461c2..bcc0924 100644 --- a/server/static-assets.js +++ b/server/static-assets.js @@ -6,7 +6,7 @@ const files = new Map([ ['/', 'index.html'], ['/widget', 'widget.html'], ...[ 'app.js', 'widget.js', 'i18n.js', 'messages.js', 'message.js', 'system-messages.js', 'access.js', 'api-client.js', - 'html.js', 'icons.js', 'task-view.js', 'task-forms.js', 'view-lifecycle.js', + 'html.js', 'icons.js', 'task-view.js', 'task-forms.js', 'view-lifecycle.js', 'settings-form.js', 'task-mode.js', 'task-details.js', 'approval-details.js', 'storage-status.js', 'quota-display.js', 'rhine-readout.js', 'style.css', 'widget.css', 'favicon.svg', 'rhine-signal-loop.svg', diff --git a/server/widget-state.cjs b/server/widget-state.cjs new file mode 100644 index 0000000..5158479 --- /dev/null +++ b/server/widget-state.cjs @@ -0,0 +1,19 @@ +const { join, resolve } = require('node:path'); + +function widgetState(dataDir, port, demo = false) { + const prefix = demo ? 'demo-' : ''; + return { + profile: join(dataDir, `${prefix}desktop-profile`), + preferences: join(dataDir, `${prefix}widget-preferences.json`), + status: join(dataDir, `${prefix}widget-status.json`), + identity: { dataDir, demo, origin: `https://127.0.0.1:${port}` }, + }; +} + +function matchesWidgetIdentity(actual, expected, platform = process.platform) { + if (!actual || typeof actual.dataDir !== 'string' || actual.demo !== expected.demo || actual.origin !== expected.origin) return false; + const normalized = value => platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); + return normalized(actual.dataDir) === normalized(expected.dataDir); +} + +module.exports = { widgetState, matchesWidgetIdentity }; diff --git a/tests/autostart-data.test.js b/tests/autostart-data.test.js new file mode 100644 index 0000000..d1fa8bb --- /dev/null +++ b/tests/autostart-data.test.js @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, mkdirSync, writeFileSync, copyFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile), root = fileURLToPath(new URL('..', import.meta.url)); +test('autostart enable and disable share runtime data selection without changing its default', { skip: process.platform !== 'win32' }, async () => { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-autostart-')); + try { + for (const variant of ['source', 'existing-data', 'portable', 'configured', 'environment']) { + for (const disable of [false, true]) { + const source = join(directory, `${variant}-${disable}-中文`), local = join(source, 'local'); + mkdirSync(join(source, 'server'), { recursive: true }); mkdirSync(join(source, 'desktop')); mkdirSync(local); + copyFileSync(join(root, 'server/config.cjs'), join(source, 'server/config.cjs')); + writeFileSync(join(source, 'package.json'), '{"version":"0.5.1"}'); + writeFileSync(join(source, 'desktop/watch-codex.ps1'), '# Not executed by fixture'); + if (['existing-data', 'portable'].includes(variant)) mkdirSync(join(source, 'data')); + if (variant === 'portable') writeFileSync(join(source, 'runtime.json'), '{"profile":"portable"}'); + if (variant === 'configured') writeFileSync(join(source, 'runtime.json'), JSON.stringify({ dataDir: join(source, 'configured-数据') })); + const env = { ...process.env, CONTINUER_DATA_DIR: variant === 'environment' ? join(source, 'override-数据') : '', LOCALAPPDATA: local, CONTINUER_PORT: '4317' }; + const selected = async () => Buffer.from((await execute(process.execPath, ['-e', 'process.stdout.write(Buffer.from(require(process.argv[1]).dataDir, "utf16le").toString("base64"))', join(source, 'server/config.cjs')], { env })).stdout, 'base64').toString('utf16le'); + const before = await selected(), trace = join(source, 'host-calls.txt'); + await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'tests/fixtures/autostart-host.ps1'), + '-Script', join(root, 'Enable-Autostart.ps1'), '-PluginRoot', source, '-Trace', trace, ...(disable ? ['-Disable'] : [])], { env, windowsHide: true }); + assert.equal(await selected(), before, `${variant}/${disable} must not change runtime default`); + const preferences = JSON.parse(readFileSync(join(before, 'autostart-preferences.json'), 'utf8').replace(/^\uFEFF/, '')); + assert.equal(preferences.enabled, !disable); + if (variant === 'source') assert.equal(existsSync(join(source, 'data')), false); + if (disable) assert.equal(existsSync(trace), false); + else assert.match(readFileSync(trace, 'utf8'), /registry:.*watch-codex\.ps1/); + } + } + } finally { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/tests/fixtures/autostart-host.ps1 b/tests/fixtures/autostart-host.ps1 new file mode 100644 index 0000000..24381eb --- /dev/null +++ b/tests/fixtures/autostart-host.ps1 @@ -0,0 +1,17 @@ +param([string]$Script, [string]$PluginRoot, [string]$Trace, [switch]$Disable) +$ErrorActionPreference = 'Stop' +# Execute the complete installer with OS boundaries mocked. File writes stay in +# the isolated fixture; registry and background launch operations are recorded. +function Get-Item { param($LiteralPath, $ErrorAction) if ($LiteralPath -notlike 'HKCU:*') { throw 'Unexpected Get-Item' }; return $null } +function Test-Path { + param($LiteralPath, $Path) + $candidate = if ($LiteralPath) { $LiteralPath } else { $Path } + if ($candidate -like 'HKCU:*') { return $true } + return Microsoft.PowerShell.Management\Test-Path -LiteralPath $candidate +} +function New-ItemProperty { param($Path, $Name, $Value, $PropertyType, [switch]$Force) Add-Content -LiteralPath $Trace -Value ('registry:' + $Value) } +function Remove-ItemProperty { throw 'No existing registry item should be removed in this fixture' } +function Start-Process { param($FilePath, $ArgumentList, $WindowStyle) Add-Content -LiteralPath $Trace -Value ('launch:' + $ArgumentList) } +function Get-CimInstance { throw 'No process inspection should occur in this fixture' } +function Stop-Process { throw 'No process should be stopped in this fixture' } +& $Script -PluginRoot $PluginRoot -Disable:$Disable diff --git a/tests/fixtures/review4-ui.cjs b/tests/fixtures/review4-ui.cjs new file mode 100644 index 0000000..3f5cea4 --- /dev/null +++ b/tests/fixtures/review4-ui.cjs @@ -0,0 +1,102 @@ +const { app, BrowserWindow } = require('electron'); +const { createServer } = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const directory = process.env.CONTINUER_TEST_REVIEW4_DIR; +if (!directory) throw Error('Isolated UI test directory required'); +app.setPath('userData', path.join(directory, 'profile')); +app.on('window-all-closed', () => {}); +const root = path.resolve(__dirname, '../..'), threadId = '77777777-7777-4777-8777-777777777777'; +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +const initial = () => ({ app: 'codex-continuer', version: '0.5.1', buildId: 'fixture', protocolVersion: 4, + settings: { enabled: false, language: 'zh', pollSeconds: 30, minimumRemaining: 5, maxRounds: 20, maxWakeSends: 8 }, + connection: { status: 'connected' }, tasks: [], approvals: [], history: [], logs: [] }); +let win, server, state = initial(); +const imports = [], results = [], errors = []; +const persist = result => fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify(result, null, 2)); +app.whenReady().then(async () => { + try { + const { createStaticAssets } = await import('../../server/static-assets.js'); + const serve = createStaticAssets(root); + server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + if (url.pathname.startsWith('/fixture/')) { + const page = url.pathname.endsWith('page') ? 'index.html' : 'widget.html'; + const html = fs.readFileSync(path.join(root, 'public', page), 'utf8').replace(/]*>[\s\S]*?<\/script>/gi, ''); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); return; + } + if (url.pathname.startsWith('/api/')) { + let body = ''; for await (const chunk of req) body += chunk; + if (url.pathname === '/api/import') { + const input = JSON.parse(body); imports.push(input); + state.tasks.push({ id: 'fixture-monitor', threadId, name: 'Fixture chat', mode: 'quota-wake', + continuous: input.startMonitoring, status: input.startMonitoring ? 'monitoring' : 'paused', sentCount: 0, detailRevision: 1 }); + } + const result = url.pathname === '/api/threads' ? { data: [{ id: threadId, name: 'Fixture chat', cwd: 'E:\\fixture' }], nextCursor: null } : state; + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); return; + } + try { if (await serve(req, res, url)) return; } catch {} + res.writeHead(404); res.end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + for (const surface of ['page', 'widget']) { + state = initial(); imports.length = 0; + win = new BrowserWindow({ show: false, width: surface === 'page' ? 1100 : 432, height: surface === 'page' ? 850 : 720, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); + win.webContents.on('console-message', details => { if (details.level === 'error') errors.push(details.message); }); + await win.loadURL(`http://127.0.0.1:${server.address().port}/fixture/${surface}`); + await win.webContents.executeJavaScript(`(async () => { + Object.defineProperty(document,'hidden',{value:false,configurable:true}); + window.createSource = () => { const source={close(){}}; window.testFeed=source; setTimeout(()=>source.onmessage({data:${JSON.stringify(JSON.stringify(state))}}),0); return source; }; + ${surface === 'widget' ? `const desktop={expanded:true,visible:true,follow:false,compactSize:256,panelSize:{width:414,height:702}}; + window.continuerDesktop={onState(callback){window.desktopListener=callback;},async control(){return {...desktop};}};` : ''} + await import('/${surface === 'page' ? 'app' : 'widget'}.js'); + })()`); + const evaluate = code => win.webContents.executeJavaScript(code); + const until = async code => { for (let i = 0; i < 100; i++) { if (await evaluate(code)) return; await delay(40); } throw Error('DOM timeout: ' + code); }; + const feed = () => evaluate(`window.testFeed.onmessage({data:${JSON.stringify(JSON.stringify(state))}})`); + if (surface === 'page') { + await until("!!document.querySelector('#main .page-heading')"); + await evaluate("document.querySelector('#nav [data-page=settings]').click()"); + await until("!!document.querySelector('#settings-form')"); + await evaluate("document.querySelector('[name=maxWakeSends]').value='11'; window.selectedInterval=document.querySelector('[name=pollSeconds]'); undefined"); + state.settings.pollSeconds = 45; await feed(); + const synced = await evaluate("({value:selectedInterval.value,submitted:new FormData(document.querySelector('#settings-form')).get('pollSeconds'),draft:document.querySelector('[name=maxWakeSends]').value})"); + assert.deepEqual(synced, { value: '45', submitted: '45', draft: '11' }); + await evaluate("selectedInterval.value='60'"); state.settings.pollSeconds = 35; await feed(); + assert.equal(await evaluate("selectedInterval.value"), '60'); + assert.equal(await evaluate("[...selectedInterval.options].some(o=>o.value==='35')"), true); + assert.deepEqual(await evaluate("[...selectedInterval.options].map(o=>o.value)"), ['10', '15', '30', '35', '45', '60', '90']); + await evaluate("selectedInterval.value='45'"); + for (const language of ['en', 'zh']) { + state.settings.language = language; await feed(); + assert.equal(await evaluate("document.querySelector('[name=pollSeconds]').value"), '45'); + assert.equal(await evaluate("document.querySelector('[name=maxWakeSends]').value"), '11'); + } + await evaluate("document.querySelector('#nav [data-page=overview]').click(); document.querySelector('[data-action=import]').click()"); + await until("!!document.querySelector('[data-action=choose-thread]')"); await evaluate("document.querySelector('[data-action=choose-thread]').click()"); + } else { + await until(`!!document.querySelector('#session-select option[value="${threadId}"]')`); + await evaluate(`document.querySelector('#session-select').value='${threadId}'; document.querySelector('#session-select').dispatchEvent(new Event('change')); document.querySelector('#add-session').click()`); + } + await until("!!document.querySelector('#import-form [name=startMonitoring]')"); + assert.equal(await evaluate("document.querySelector('[name=startMonitoring]').checked"), false); + assert.equal(await evaluate("document.querySelector('[name=startMonitoring]').parentElement.textContent.includes('加入后开始监测')"), true); + await evaluate("document.querySelector('[name=startMonitoring]').checked=true; document.querySelector('[name=note]').value='Local note'; document.querySelector('[name=continuationMessage]').value='Continue this'"); + assert.equal(await evaluate("document.querySelector('[name=startMonitoring]').getClientRects().length > 0"), true); + state.settings.language = 'en'; await feed(); + assert.equal(await evaluate("document.querySelector('[name=startMonitoring]').checked"), true); + assert.equal(await evaluate("document.querySelector('[name=startMonitoring]').parentElement.textContent.includes('Start monitoring after adding')"), true); + await evaluate("document.querySelector('#import-form').requestSubmit()"); + await until("!document.querySelector('#modal').open"); + assert.equal(imports.length, 1); + assert.deepEqual(imports[0], { threadId, note: 'Local note', continuationMessage: 'Continue this', startMonitoring: true }); + results.push({ surface, import: imports[0], defaultPaused: true, checkboxPreservedAcrossLanguage: true }); + win.destroy(); win = null; + } + persist({ passed: true, results }); + } catch (error) { persist({ error: error.stack, errors }); process.exitCode = 1; } + finally { win?.destroy(); if (server) await new Promise(resolve => server.close(resolve)); app.exit(process.exitCode || 0); } +}); +setTimeout(() => { persist({ error: 'UI fixture timed out', errors }); app.exit(2); }, 20000); diff --git a/tests/fixtures/shutdown-owned-service.cjs b/tests/fixtures/shutdown-owned-service.cjs index 4ac400d..959744a 100644 --- a/tests/fixtures/shutdown-owned-service.cjs +++ b/tests/fixtures/shutdown-owned-service.cjs @@ -2,7 +2,8 @@ const { spawn } = require('node:child_process'); const { writeFileSync } = require('node:fs'); if (process.argv[2] !== '--child') { - const child = spawn(process.execPath, [__filename, '--child'], { stdio: 'ignore', windowsHide: true }); + const child = spawn(process.execPath, [__filename, '--child'], { stdio: 'ignore', windowsHide: true, detached: true }); writeFileSync(process.argv[2], JSON.stringify({ pid: child.pid })); + process.on('message', message => { if (message === 'exit') process.exit(0); }); } setInterval(() => {}, 1000); diff --git a/tests/fixtures/watcher-host.ps1 b/tests/fixtures/watcher-host.ps1 new file mode 100644 index 0000000..e6cb9b2 --- /dev/null +++ b/tests/fixtures/watcher-host.ps1 @@ -0,0 +1,18 @@ +param([string]$Watcher, [string]$NodePath, [string]$PluginRoot, [string]$DataDir) +$ErrorActionPreference = 'Stop' +# Only this child process gets a synthetic desktop and mutex. The original +# watcher runs unchanged; its child launcher is a read-only config probe. +Microsoft.PowerShell.Utility\Add-Type -TypeDefinition 'public static class ContinuerWindows { public static string DesktopKey() { return "fixture-desktop"; } }' +function Add-Type { param($Path) if (-not $Path.EndsWith('codex-windows.cs')) { throw 'Unexpected type load' } } +function New-Object { + $mutex = [pscustomobject]@{} + $mutex | Add-Member -MemberType ScriptMethod -Name WaitOne -Value { param($timeout) return $true } + $mutex | Add-Member -MemberType ScriptMethod -Name ReleaseMutex -Value {} + $mutex | Add-Member -MemberType ScriptMethod -Name Dispose -Value {} + return $mutex +} +function Start-Sleep { param($Seconds) Set-Content -LiteralPath (Join-Path $DataDir 'quit.request') -Value 'test complete' } +function Start-Process { throw 'No native watcher should be started by this fixture' } +$continuerOriginalData = $env:CONTINUER_DATA_DIR +& $Watcher -NodePath $NodePath -PluginRoot $PluginRoot -DataDir $DataDir +if ([string]$env:CONTINUER_DATA_DIR -ne [string]$continuerOriginalData) { throw 'Caller data environment was not restored' } diff --git a/tests/fixtures/widget-instance.cjs b/tests/fixtures/widget-instance.cjs new file mode 100644 index 0000000..a8273e0 --- /dev/null +++ b/tests/fixtures/widget-instance.cjs @@ -0,0 +1,38 @@ +// Use real Electron single-instance locks with no renderer, service or watcher. +const electron = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const load = createRequire(path.resolve(__dirname, '../../desktop/main.cjs')); +const { createDesktopRuntime } = load('./runtime.cjs'); +const directory = process.env.CONTINUER_TEST_INSTANCE_DIR; +const mode = process.env.CONTINUER_TEST_INSTANCE_MODE; +if (!directory || !['live', 'demo'].includes(mode)) throw Error('Isolated instance test configuration required'); +const record = { mode, secondInstances: 0 }, output = path.join(directory, mode + (process.argv.includes('--auto') ? '-duplicate' : '') + '.json'); +const persist = () => fs.writeFileSync(output, JSON.stringify(record)); +process.on('uncaughtException', error => { record.error = error.stack; persist(); electron.app.exit(1); }); +const preferences = { follow: false, expanded: false, hidden: true, compactSize: 160, offsets: {}, positions: {} }; +const app = { + setName: value => electron.app.setName(value), + setPath: (name, value) => { fs.mkdirSync(value, { recursive: true }); record[name] = value; electron.app.setPath(name, value); }, + requestSingleInstanceLock: identity => { record.identity = identity; record.acquired = electron.app.requestSingleInstanceLock(identity); persist(); return record.acquired; }, + quit: () => electron.app.exit(0), + whenReady: () => ({ then: () => ({ catch() {} }) }), + on: (name, handler) => { + if (name === 'second-instance') electron.app.on(name, (...args) => { + handler(...args); record.secondInstances++; record.hidden = layout.preferences.hidden; persist(); + }); + }, +}; +const layout = createDesktopRuntime({ + // No window is supplied: duplicate --auto requests cannot load a renderer. + processInfo: { env: { CONTINUER_DEMO: mode === 'demo' ? '1' : '' }, argv: process.argv, pid: process.pid, platform: process.platform }, + load: name => name === 'electron' ? { app } : + name === '../server/config.cjs' ? { dataDir: directory, port: Number(process.env.CONTINUER_TEST_INSTANCE_PORT || (mode === 'demo' ? 4318 : 4317)), appInfo: () => ({ buildId: 'fixture' }) } : + name === '../server/logging.cjs' ? { installLogs() {} } : + name === 'node:fs' ? { mkdirSync() {}, readFileSync: () => JSON.stringify(preferences), writeFileSync() {}, existsSync: () => false } : load(name), +}); +electron.app.whenReady().then(() => { record.ready = true; persist(); }); +const stop = path.join(directory, mode + '.stop'); +setInterval(() => { if (fs.existsSync(stop)) electron.app.exit(0); }, 50); +setTimeout(() => electron.app.exit(2), 12000); diff --git a/tests/fixtures/widget-view.js b/tests/fixtures/widget-view.js index 3ef02f7..83646d0 100644 --- a/tests/fixtures/widget-view.js +++ b/tests/fixtures/widget-view.js @@ -25,7 +25,7 @@ function interfaceView(surface, { reducedMotion = true, threads = async () => ({ const document = { hidden: false, activeElement: null, body: { classList: classes(surface === 'widget' ? ['collapsed'] : []) }, documentElement: { style: { setProperty() {} } }, querySelector, querySelectorAll: () => [], importNode: value => value, addEventListener(name, callback) { events.set(name, callback); } }; class ViewDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } } class ViewFormData { - constructor(form) { this.values = form.values || {}; } + constructor(form) { this.values = form === element('#settings-form') ? Object.fromEntries(['minimumRemaining', 'pollSeconds', 'maxRounds', 'maxWakeSends'].map(key => [key, element(`#settings-form [name=${key}]`).value])) : form.values || {}; } [Symbol.iterator]() { return Object.entries(this.values)[Symbol.iterator](); } } const context = { document, window: { continuerDesktop: surface === 'widget' ? bridge : undefined, matchMedia: () => ({ matches: reducedMotion }), addEventListener() {} }, localStorage: { getItem: () => null, setItem() {} }, Date: ViewDate, FormData: ViewFormData, @@ -45,6 +45,7 @@ function interfaceView(surface, { reducedMotion = true, threads = async () => ({ page(page) { return events.get('click')({ target: { closest: () => ({ dataset: { page } }) } }); }, submit(id, values, dataset = {}) { const form = element('#' + id); Object.assign(form, { id, values, dataset }); + if (id === 'settings-form') for (const [key, value] of Object.entries(values)) element(`#settings-form [name=${key}]`).value = String(value); return events.get('submit')({ target: form, preventDefault() {} }); }, visible(value) { document.hidden = !value; events.get('visibilitychange')?.(); }, diff --git a/tests/import-http.test.js b/tests/import-http.test.js index 8b8193d..e47cc15 100644 --- a/tests/import-http.test.js +++ b/tests/import-http.test.js @@ -107,6 +107,15 @@ test('HTTP and MCP imports share one reservation and persist one recovery delive const ended = await request('/api/state'); assert.equal(ended.tasks.some(task => task.id === capped.id), false); assert.match(ended.logs.find(log => log.taskId === capped.id).message, /结束监测/); + const enabledBefore = (await request('/api/state')).settings.enabled; + const started = await request('/api/import', { threadId: '44444444-4444-4444-8444-444444444444', note: 'Start on add', startMonitoring: true }); + const watching = started.tasks.find(task => task.threadId === '44444444-4444-4444-8444-444444444444'); + assert.equal(watching.continuous, true); assert.equal(watching.status, 'monitoring'); + assert.equal(started.settings.enabled, enabledBefore); + assert.equal(JSON.parse(readFileSync(join(dataDir, 'demo-state.json'), 'utf8')).tasks.find(task => task.id === watching.id).continuous, true); + await assert.rejects(request('/api/import', { threadId: 'not-read', startMonitoring: 'on' }), /开关/); + await assert.rejects(request('/api/import', { threadId: 'not-read', startMonitoring: null }), /开关/); + await assert.rejects(dispatch('continuer_import_session', { threadId: 'not-read', startMonitoring: true }, runtime), /startMonitoring/); } finally { if (child.exitCode === null) child.kill(); await exited; diff --git a/tests/import.test.js b/tests/import.test.js index 8ec3bba..0cb3c05 100644 --- a/tests/import.test.js +++ b/tests/import.test.js @@ -47,6 +47,31 @@ test('20 parallel imports create one paused record and one message per quota rec assert.deepEqual(sends, [threadId, threadId]); assert.equal(task.sentCount, 2); }); +test('optional monitoring is saved with the imported record and waits for a recovery episode', async () => { + const { m, saves, sends } = setup(); m.observationOnly = true; + const task = await m.importThread(threadId, 'Local note', 'Continue this', { startMonitoring: true }); + assert.equal(task.continuous, true); assert.equal(task.status, 'monitoring'); assert.equal(m.observationOnly, false); + assert.equal(saves.length, 1); assert.equal(saves[0].tasks[0].continuous, true); + assert.equal(saves[0].tasks[0].wake.armed, false); + m.setQuota(quota(0)); await m.tick(); assert.deepEqual(sends, []); + m.setQuota(quota(100)); await m.tick(); m.setQuota(quota(0)); await m.tick(); + assert.deepEqual(sends, [threadId]); +}); + +test('enabling monitoring on import respects global pause, invalid values and failed persistence', async () => { + const { m, reads, saves, sends } = setup(); + for (const startMonitoring of ['on', null, 1]) await assert.rejects(m.importThread(threadId, '', '', { startMonitoring }), /开关/); + assert.deepEqual(reads, []); assert.deepEqual(saves, []); + m.updateSettings({ enabled: false }); + const task = await m.importThread(threadId, '', '', { startMonitoring: true }); + m.setQuota(quota(100)); await m.tick(); m.setQuota(quota(0)); await m.tick(); + assert.equal(task.continuous, true); assert.equal(m.state.settings.enabled, false); assert.deepEqual(sends, []); + const failed = setup(); failed.m.observationOnly = true; failed.m.store.save = () => { throw Error('Fixture disk failure'); }; + await assert.rejects(failed.m.importThread(threadId, '', '', { startMonitoring: true }), /disk failure/); + assert.equal(failed.m.observationOnly, true); assert.equal(failed.m.persistence.status, 'error'); + failed.m.setQuota(quota(0)); await failed.m.tick(); assert.deepEqual(failed.sends, []); +}); + test('a failed read releases the import reservation and permits a retry', async () => { const gate = deferred(); let attempts = 0; const { m, reads, saves } = setup(id => ++attempts === 1 ? gate.promise : Promise.resolve(thread(id))); diff --git a/tests/monitor-policy.test.js b/tests/monitor-policy.test.js index 4369778..07bb73a 100644 --- a/tests/monitor-policy.test.js +++ b/tests/monitor-policy.test.js @@ -23,6 +23,7 @@ test('backend accepts 90 seconds while MCP cannot change execution policies', () const { m } = fixture(); m.updateSettings({ pollSeconds: 90, maxWakeSends: 1 }); assert.throws(() => validate('continuer_update_settings', { pollSeconds: 90, maxWakeSends: 1 })); + assert.throws(() => validate('continuer_import_session', { threadId, startMonitoring: true })); const before = structuredClone(m.state.settings); for (const input of [{ pollSeconds: 91 }, { pollSeconds: 300 }, { pollSeconds: 9 }, { pollSeconds: 90.1 }, { maxWakeSends: 0 }, { maxWakeSends: 101 }]) { assert.throws(() => m.updateSettings({ enabled: false, ...input })); diff --git a/tests/public-privacy.test.js b/tests/public-privacy.test.js index 895c7f3..0bae259 100644 --- a/tests/public-privacy.test.js +++ b/tests/public-privacy.test.js @@ -11,6 +11,11 @@ test('public privacy guard rejects live identifiers in every exported text surfa t.after(() => { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); }); for (const name of publicFiles()) { const target = join(directory, name); mkdirSync(dirname(target), { recursive: true }); copyFileSync(name, target); } assert.ok(checkPublic(directory).files > 100); + const reviewDirectory = join(directory, 'docs/.review-private'); mkdirSync(reviewDirectory); + writeFileSync(join(reviewDirectory, 'REPORT.md'), 'Local review only'); + writeFileSync(join(directory, 'tests/assessment.md'), 'Local assessment only'); + assert.equal(publicFiles(directory).some(name => /assessment|\.review/i.test(name)), false); + assert.ok(publicFiles(directory).includes('docs/project-evaluation.md')); const credentialFile = join(directory, 'tests/quota-wake.test.js'), credentialOriginal = readFileSync(credentialFile); writeFileSync(credentialFile, credentialOriginal.toString() + '\nBearer ' + 'a'.repeat(64)); assert.throws(() => checkPublic(directory), /local Bearer credential/); diff --git a/tests/quota-wake.test.js b/tests/quota-wake.test.js index 4df0dae..f3e9ea2 100644 --- a/tests/quota-wake.test.js +++ b/tests/quota-wake.test.js @@ -4,6 +4,8 @@ import { EventEmitter } from 'node:events'; import { Manager } from '../server/manager.js'; import { QueueSender } from '../server/queue-sender.js'; import { interruptedThread } from './fixtures/thread-history.js'; +import { taskStatusLabel, taskCardHtml } from '../public/task-view.js'; +import { t as tr } from '../public/i18n.js'; const threadId = '11111111-1111-4111-8111-111111111111'; const sample = (used, secondary = 10) => ({ rateLimits: { primary: { usedPercent: used }, secondary: { usedPercent: secondary } } }); @@ -91,6 +93,26 @@ test('preflight failure retries with backoff; repeating enable does not clear an assert.equal(t.waitReason, 'desktop'); assert.equal(t.wake.delivery, 'pending'); }); +test('an uncertain delivery clears retry state and both card layouts require attention', async () => { + let calls = 0; + const sender = new QueueSender({ binary: 'fixture', run: async (_binary, args) => { + calls++; + if (calls === 1) throw Error('Fixture capability probe failed'); + if (args.includes('--help')) return 'help'; + throw Error('Fixture exit after possible delivery'); + } }); + const { m, t } = await setup(null, (...args) => sender.send(...args)); + m.setQuota(sample(100)); await m.tick(); m.setQuota(sample(0)); await m.tick(); + assert.equal(t.waitReason, 'desktop'); assert.equal(taskStatusLabel(t), tr('ui.waiting_for_delivery')); + t.retryAt = 0; await m.tick(); + assert.equal(t.status, 'blocked'); assert.equal(t.wake.delivery, 'uncertain'); + assert.equal(t.waitReason, null); assert.equal(t.retryAt, 0); + assert.equal(taskStatusLabel(t), tr('ui.needs_attention')); + for (const layout of ['page', 'widget']) assert.ok(taskCardHtml(t, m.state.settings, layout).includes(tr('ui.needs_attention'))); + const count = calls; await m.tick(); assert.equal(calls, count); + assert.equal(taskStatusLabel({ ...t, waitReason: 'desktop' }), tr('ui.needs_attention'), 'Old saved wait reasons cannot override a blocked status'); +}); + test('legacy writer wait migrates to monitoring without fabricating an exhaustion event', async () => { const { m, t } = await setup(); delete t.mode; delete t.wake; t.status = 'waiting'; t.waitReason = 'writer'; diff --git a/tests/reliability.test.js b/tests/reliability.test.js index 5ce89ac..6ad23eb 100644 --- a/tests/reliability.test.js +++ b/tests/reliability.test.js @@ -185,10 +185,13 @@ test('service leases prevent duplicate writers and release only their own lock', test('health reports previous crashes, BOM diagnostics and actual process liveness', t => { const directory = temporary(t); writeFileSync(join(directory, 'service-status.json'), JSON.stringify({ status: 'running' })); writeFileSync(join(directory, 'autostart-preferences.json'), '\uFEFF{"enabled":false}'); - writeFileSync(join(directory, 'widget-status.json'), JSON.stringify({ pid: process.pid, ready: true })); + writeFileSync(join(directory, 'widget-status.json'), JSON.stringify({ pid: process.pid, ready: false })); + writeFileSync(join(directory, 'demo-widget-status.json'), JSON.stringify({ pid: process.pid, ready: true })); const { m } = manager({ load: () => null, save: () => {} }); const health = new Health(directory, m, new StateStream()); assert.equal(health.snapshot().previousExit.unexpected, true); assert.equal(health.snapshot().autostart.enabled, false); - assert.equal(health.snapshot().widget.ready, true); health.record('stopped', 'manual'); + assert.equal(health.snapshot().widget.ready, true); + m.demo = false; assert.equal(health.snapshot().widget.ready, false); m.demo = true; + health.record('stopped', 'manual'); assert.equal(diagnostic(join(directory, 'service-status.json')).reason, 'manual'); m.stop(); }); diff --git a/tests/review4-ui.test.js b/tests/review4-ui.test.js new file mode 100644 index 0000000..53f7b7b --- /dev/null +++ b/tests/review4-ui.test.js @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync, realpathSync, existsSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +const executable = fileURLToPath(new URL('../node_modules/electron/dist/electron.exe', import.meta.url)); +test('real DOM retains custom intervals and the add-and-monitor choice in both languages and layouts', + { skip: process.platform !== 'win32' || !existsSync(executable), timeout: 25000 }, async () => { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-review4-ui-')); + try { + try { + await promisify(execFile)(executable, [fileURLToPath(new URL('./fixtures/review4-ui.cjs', import.meta.url))], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined, NODE_OPTIONS: '', CONTINUER_TEST_REVIEW4_DIR: directory }, windowsHide: true, timeout: 23000, + }); + } catch (error) { if (existsSync(join(directory, 'result.json'))) error.message += '\n' + readFileSync(join(directory, 'result.json'), 'utf8'); throw error; } + const result = JSON.parse(readFileSync(join(directory, 'result.json'), 'utf8')); + assert.equal(result.error, undefined); assert.equal(result.passed, true); assert.equal(result.results.length, 2); + } finally { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } + }); diff --git a/tests/runtime-lifecycle.test.js b/tests/runtime-lifecycle.test.js index 3def9f1..f9ff8b9 100644 --- a/tests/runtime-lifecycle.test.js +++ b/tests/runtime-lifecycle.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { basename, join } from 'node:path'; import { createLifecycle } from '../plugin/lifecycle.js'; +import widgetStateModule from '../server/widget-state.cjs'; const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; const unavailable = () => Object.assign(Error('No listener'), { cause: { code: 'ECONNREFUSED' } }); @@ -23,7 +24,10 @@ function fixture(overrides = {}) { const lifecycle = createLifecycle(host); return { lifecycle, host, state, info, launches, reads, diagnostics, set exited(value) { exited = value; }, get exited() { return exited; }, get clears() { return clears; }, get now() { return clock; } }; } -const readyWidget = f => f.diagnostics.set('widget-status.json', { ready: true, buildId: f.info.buildId, pid: 123, follow: false }); +const readyWidget = f => { + const files = widgetStateModule.widgetState(f.host.dataDir, f.host.port, f.host.demo || false); + f.diagnostics.set(basename(files.status), { ...files.identity, ready: true, buildId: f.info.buildId, pid: 123, follow: false }); +}; test('constructing a lifecycle does no I/O and a warm query reuses the verified snapshot without altering scheduling', async () => { const f = fixture(); assert.equal(f.reads.length, 0); assert.equal(f.launches.length, 0); @@ -184,6 +188,19 @@ test('widget readiness requires a live process from this build and a failed open assert.equal(f.launches.length, 3); assert.ok(f.launches.every(item => item.child.listenerCount('error') === 1)); }); +test('widget readiness rejects another mode, origin, directory and legacy diagnostics', async () => { + for (const patch of [{ demo: true }, { origin: 'https://127.0.0.1:4318' }, { dataDir: 'other' }, { dataDir: undefined }]) { + const f = fixture({ widgetAttempts: 1 }); readyWidget(f); + Object.assign(f.diagnostics.get('widget-status.json'), patch); + await assert.rejects(f.lifecycle.openWidget(), { code: 'WIDGET_START_TIMEOUT' }); + } + const demo = fixture({ demo: true, port: 4318, widgetAttempts: 1 }); + demo.diagnostics.set('widget-status.json', { ready: true, buildId: demo.info.buildId, pid: 123, follow: false }); + await assert.rejects(demo.lifecycle.openWidget(), { code: 'WIDGET_START_TIMEOUT' }); + readyWidget(demo); + assert.equal((await demo.lifecycle.openWidget()).ready, true); +}); + test('widget spawn errors release the opening promise without starting a watcher', async () => { let fail = true; const error = Error('Mock Electron spawn failed'); const f = fixture({ wait: async () => { if (fail) { fail = false; f.launches.at(-1).child.emit('error', error); } } }); diff --git a/tests/settings-recovery.test.js b/tests/settings-recovery.test.js new file mode 100644 index 0000000..1482854 --- /dev/null +++ b/tests/settings-recovery.test.js @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { Manager } from '../server/manager.js'; +import { initialState } from '../server/state-lifecycle.js'; +import { pageView, widgetView } from './fixtures/widget-view.js'; + +for (const [surface, createView] of [['page', pageView], ['widget', widgetView]]) { + test(`${surface} restores backup settings before saving an unrelated field`, async () => { + const current = initialState(false), backup = initialState(false); + current.settings.maxWakeSends = 8; backup.settings.maxWakeSends = 3; + const rpc = new EventEmitter(); rpc.ready = false; + const manager = new Manager(rpc, { load: () => current, save() {}, status: () => ({ backupAvailable: true }), + restoreBackup: prepare => { const state = structuredClone(backup); prepare(state); return state; }, + }, { demo: true }); + const view = createView({ request: async (path, options) => { + if (path.startsWith('/api/threads')) return { data: [], nextCursor: null }; + if (path === '/api/storage/recover') manager.recoverStorage(JSON.parse(options.body).source); + if (path === '/api/settings') manager.updateSettings(JSON.parse(options.body)); + return manager.snapshot(); + } }); + await view.flush(); view.message(0, manager.snapshot()); + if (surface === 'widget') view.desktop({ expanded: true, visible: true }); else await view.page('settings'); + assert.equal(Number(view.element('#settings-form [name=maxWakeSends]').value), 8); + view.element('#settings-form [name=minimumRemaining]').value = '17'; + manager.storageFailure(Error('Fixture disk failure')); view.message(0, manager.snapshot()); + if (surface === 'page') await view.page('overview'); + await view.action('storage-backup'); + if (surface === 'page') await view.page('settings'); + assert.equal(Number(view.element('#settings-form [name=maxWakeSends]').value), 3); + assert.equal(Number(view.element('#settings-form [name=minimumRemaining]').value), backup.settings.minimumRemaining); + await view.submit('settings-form', { pollSeconds: '60' }); + assert.deepEqual(JSON.parse(view.apiCalls.find(call => call.path === '/api/settings').options.body), { pollSeconds: 60 }); + assert.equal(manager.state.settings.maxWakeSends, 3); + assert.equal(manager.state.settings.pollSeconds, 60); + assert.equal(manager.state.settings.enabled, false); + view.visible(false); if (surface === 'widget') view.desktop({ visible: false }); + }); + + test(`${surface} keeps drafts while updating clean fields and submits only edits`, async () => { + let state = { ...initialState(false), connection: { status: 'connected' }, approvals: [], version: '0.5.1', buildId: 'fixture' }; + const view = createView({ request: async (path, options) => { + if (path.startsWith('/api/threads')) return { data: [], nextCursor: null }; + if (path === '/api/settings') state = { ...state, settings: { ...state.settings, ...JSON.parse(options.body) } }; + return state; + } }); + await view.flush(); view.message(0, state); + if (surface === 'widget') view.desktop({ expanded: true, visible: true }); else await view.page('settings'); + view.element('#settings-form [name=pollSeconds]').value = '60'; + state = { ...state, settings: { ...state.settings, maxWakeSends: 3 } }; + view.message(0, state); + assert.equal(String(view.element('#settings-form [name=pollSeconds]').value), '60'); + assert.equal(Number(view.element('#settings-form [name=maxWakeSends]').value), 3); + const values = Object.fromEntries(['minimumRemaining', 'pollSeconds', 'maxRounds', 'maxWakeSends'] + .map(key => [key, view.element(`#settings-form [name=${key}]`).value])); + await view.submit('settings-form', values); + assert.deepEqual(JSON.parse(view.apiCalls.find(call => call.path === '/api/settings').options.body), { pollSeconds: 60 }); + state = { ...state, settings: { ...state.settings, pollSeconds: 15 } }; view.message(0, state); + assert.equal(Number(view.element('#settings-form [name=pollSeconds]').value), 15, 'Saved edits must become clean'); + view.visible(false); if (surface === 'widget') view.desktop({ visible: false }); + }); +} diff --git a/tests/shared-ui.test.js b/tests/shared-ui.test.js index 1f7b4f6..410c84a 100644 --- a/tests/shared-ui.test.js +++ b/tests/shared-ui.test.js @@ -90,8 +90,10 @@ test('shared forms retain field contracts and never translate imported chat name const form = monitorImportForm('\"', { layout, thread: { name: '继续处理中文项目', cwd: '' } }); assert.match(form, /name="continuationMessage" maxlength="12000"/); assert.match(form, /name="note" maxlength="12000"/); + assert.match(form, /name="startMonitoring" type="checkbox"/); + assert.doesNotMatch(form, /name="startMonitoring"[^>]*checked/); assert.match(form, /继续处理中文项目/); assert.doesNotMatch(form, /|"/); - if (language === 'en') assert.match(form, /Continuation message/); + if (language === 'en') { assert.match(form, /Continuation message/); assert.match(form, /Start monitoring after adding/); } } } }); @@ -125,7 +127,16 @@ for (const [layout, createView] of [['page', pageView], ['widget', widgetView]]) await view.action(layout === 'page' ? 'choose-thread' : 'select-session', 'chat'); await view.submit('import-form', { note: 'Local note', continuationMessage: 'Continue this' }, { id: 'chat' }); const imported = view.apiCalls.find(call => call.path === '/api/import'); - assert.deepEqual(JSON.parse(imported.options.body), { threadId: 'chat', note: 'Local note', continuationMessage: 'Continue this' }); + assert.deepEqual(JSON.parse(imported.options.body), { threadId: 'chat', note: 'Local note', continuationMessage: 'Continue this', startMonitoring: false }); + view.visible(false); if (layout === 'widget') view.desktop({ visible: false }); + }); + + test(`${layout} can enable monitoring while adding a chat`, async () => { + const state = snapshot([]), view = createView({ request: async () => state }); + await view.flush(); view.message(0, state); await view.action(layout === 'page' ? 'choose-thread' : 'select-session', 'chat'); + await view.submit('import-form', { note: 'Local note', continuationMessage: 'Continue this', startMonitoring: 'on' }, { id: 'chat' }); + const imported = view.apiCalls.find(call => call.path === '/api/import'); + assert.deepEqual(JSON.parse(imported.options.body), { threadId: 'chat', note: 'Local note', continuationMessage: 'Continue this', startMonitoring: true }); view.visible(false); if (layout === 'widget') view.desktop({ visible: false }); }); } diff --git a/tests/shutdown.test.js b/tests/shutdown.test.js index bc18026..585b919 100644 --- a/tests/shutdown.test.js +++ b/tests/shutdown.test.js @@ -25,27 +25,66 @@ function fixture(t) { test('exit latches before stopping the watcher and shuts down only its service', async t => { const f = fixture(t); await stopBackground(f.options); - assert.deepEqual(f.calls, [['watcher', undefined], '/api/state', '/api/shutdown', ['service', 123]]); + assert.deepEqual(f.calls, [['watcher', undefined], '/api/state', ['capture', 123], '/api/shutdown', ['service', 123]]); assert.equal(exitState.requested(f.options.dataDir), true); }); test('shutdown timeouts still run verified process cleanup', async t => { const f = fixture(t); f.options.request = async () => { throw Object.assign(Error('not reachable'), { cause: { code: 'ECONNREFUSED' } }); }; - await stopBackground(f.options); assert.deepEqual(f.calls, [['watcher', undefined], ['service', 123]]); + await stopBackground(f.options); assert.deepEqual(f.calls, [['watcher', undefined], ['capture', 123], ['service', 123]]); }); test('a service on another data directory never receives a shutdown request', async t => { const f = fixture(t); f.options.request = async path => { assert.notEqual(path, '/api/shutdown'); return { app: 'other' }; }; - await stopBackground(f.options); assert.deepEqual(f.calls, [['watcher', undefined], ['service', 123]]); + await stopBackground(f.options); assert.deepEqual(f.calls, [['watcher', undefined], ['capture', 123], ['service', 123]]); }); test('demo exit leaves live markers and the startup watcher alone', async t => { - const f = fixture(t); f.options.demo = true; f.options.cleanup = async phase => { assert.equal(phase, 'service'); }; + const f = fixture(t); f.options.demo = true; f.options.cleanup = async phase => { assert.ok(['capture', 'service'].includes(phase)); }; f.options.request = async path => path === '/api/shutdown' ? {} : { app: 'codex-continuer', diagnostics: { projectRoot: f.options.root, dataDir: f.options.dataDir, pid: 123 } }; f.options.isAlive = () => false; await stopBackground(f.options); assert.equal(exitState.requested(f.options.dataDir, true), true); assert.equal(exitState.requested(f.options.dataDir), false); }); +test('exit cleans captured descendants even after the service exits gracefully', async t => { + const f = fixture(t), snapshot = [{ pid: 123 }, { pid: 456 }]; let captured = false, cleaned = false; + const request = f.options.request; + f.options.request = async (...args) => { if (args[0] === '/api/shutdown') assert.equal(captured, true); return request(...args); }; + f.options.cleanup = async (phase, pid, saved) => { + if (phase === 'capture') { captured = true; return snapshot; } + if (phase === 'service') { assert.equal(f.options.isAlive(pid), false); assert.equal(saved, snapshot); cleaned = true; } + }; + await stopBackground(f.options); assert.equal(cleaned, true); +}); + +test('snapshot failure still attempts graceful shutdown and verified force cleanup', async t => { + const f = fixture(t), cleanup = f.options.cleanup; + f.options.cleanup = async (...args) => { if (args[0] === 'capture') throw Error('snapshot unavailable'); return cleanup(...args); }; + await assert.rejects(stopBackground(f.options), /snapshot unavailable/); + assert.ok(f.calls.includes('/api/shutdown')); assert.deepEqual(f.calls.at(-1), ['service', 123]); +}); + +test('full exit removes orphaned descendants without stopping an unrelated process', { skip: process.platform !== 'win32', timeout: 30000 }, async t => { + const dataDir = directory(t), root = join(dataDir, '插件 with spaces'); mkdirSync(join(root, 'server'), { recursive: true }); mkdirSync(join(root, 'plugin')); + const script = join(root, 'server/index.js'), childFile = join(dataDir, 'child.json'); + copyFileSync(new URL('./fixtures/shutdown-owned-service.cjs', import.meta.url), script); + copyFileSync(new URL('../plugin/stop-owned.ps1', import.meta.url), join(root, 'plugin/stop-owned.ps1')); + const server = spawn(process.execPath, [script, childFile], { windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }), exited = once(server, 'exit'); + let childPid; + t.after(() => { if (server.exitCode === null) server.kill(); if (childPid && alive(childPid)) process.kill(childPid); }); + for (let i = 0; i < 40; i++) { try { childPid = JSON.parse(readFileSync(childFile, 'utf8')).pid; break; } catch { await delay(50); } } + assert.ok(childPid); + let orphanObserved = false; + await stopBackground({ root, dataDir, demo: true, request: async path => { + if (path === '/api/state') return { app: 'codex-continuer', diagnostics: { projectRoot: root, dataDir, pid: server.pid } }; + server.send('exit'); await exited; + orphanObserved = alive(childPid); + return {}; + } }); + for (let i = 0; i < 30 && alive(childPid); i++) await delay(50); + assert.equal(orphanObserved, true); assert.equal(alive(childPid), false); assert.equal(alive(process.pid), true); +}); + test('passive reads and automatic opening do not restart an exited app; manual opening clears the latch', t => { const dataDir = directory(t); exitState.mark(dataDir); const probe = fileURLToPath(new URL('./fixtures/shutdown-passive.mjs', import.meta.url)); @@ -67,6 +106,16 @@ test('verified force cleanup terminates an owned service tree and refuses unrela assert.ok(childPid); await assert.rejects(execute('powershell.exe', args(process.pid), { windowsHide: true, timeout: 10000 }), /Refusing to stop/); assert.equal(alive(process.pid), true); + const captureArgs = args(server.pid); captureArgs[captureArgs.indexOf('service')] = 'capture'; + const snapshot = JSON.parse((await execute('powershell.exe', captureArgs, { windowsHide: true, timeout: 10000 })).stdout); + assert.ok(snapshot.some(item => item.pid === childPid)); + // Recycled PIDs can have the same executable and command line; creation time distinguishes them. + for (const item of snapshot) item.created = '0'; + await new Promise((resolve, reject) => { + const cleaner = execFile('powershell.exe', [...args(server.pid), '-ReadSnapshot'], { windowsHide: true, timeout: 10000 }, error => error ? reject(error) : resolve()); + cleaner.stdin.end(JSON.stringify(snapshot)); + }); + assert.equal(alive(server.pid), true); assert.equal(alive(childPid), true); await execute('powershell.exe', args(server.pid), { windowsHide: true, timeout: 10000 }); await exited; for (let i = 0; i < 30 && alive(childPid); i++) await delay(50); assert.equal(alive(childPid), false); diff --git a/tests/watcher-data.test.js b/tests/watcher-data.test.js new file mode 100644 index 0000000..951f38e --- /dev/null +++ b/tests/watcher-data.test.js @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +const root = fileURLToPath(new URL('..', import.meta.url)); +test('PowerShell fallback passes its explicit data directory and restores the caller environment', { skip: process.platform !== 'win32' }, async () => { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-watcher-data-')); + try { + for (const inherited of ['', 'another-directory']) { + const source = join(directory, inherited || 'without-env'), dataDir = join(source, 'selected-数据'); + for (const name of ['server', 'desktop', 'plugin', 'local', 'selected-数据']) mkdirSync(join(source, name), { recursive: true }); + copyFileSync(join(root, 'desktop/watch-codex.ps1'), join(source, 'desktop/watch-codex.ps1')); + copyFileSync(join(root, 'server/config.cjs'), join(source, 'server/config.cjs')); + writeFileSync(join(source, 'package.json'), '{"version":"0.5.1"}'); + writeFileSync(join(source, 'plugin/open.js'), "console.log(Buffer.from(require('../server/config.cjs').dataDir,'utf16le').toString('base64'));"); + await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'tests/fixtures/watcher-host.ps1'), + '-Watcher', join(source, 'desktop/watch-codex.ps1'), '-NodePath', process.execPath, '-PluginRoot', source, '-DataDir', dataDir], { + env: { ...process.env, CONTINUER_DATA_DIR: inherited, LOCALAPPDATA: join(source, 'local'), CONTINUER_PORT: '4317' }, windowsHide: true, timeout: 15000, + }); + const bytes = readFileSync(join(dataDir, 'autostart-launch.log')); + const log = bytes.toString(bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf16le' : 'utf8').replace(/^\uFEFF/, '').trim(); + assert.equal(Buffer.from(log, 'base64').toString('utf16le'), dataDir); + } + } finally { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/tests/widget-instance.test.js b/tests/widget-instance.test.js new file mode 100644 index 0000000..3ecb680 --- /dev/null +++ b/tests/widget-instance.test.js @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync, realpathSync, existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +const executable = fileURLToPath(new URL('../node_modules/electron/dist/electron.exe', import.meta.url)); +const fixture = fileURLToPath(new URL('./fixtures/widget-instance.cjs', import.meta.url)); +for (const order of [['live', 'demo'], ['demo', 'live']]) { + test(`native widget locks isolate ${order.join(' then ')} and route duplicate requests to their own mode`, + { skip: process.platform !== 'win32' || !existsSync(executable), timeout: 25000 }, async () => { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-widget-instance-')); + const env = { ...process.env, ELECTRON_RUN_AS_NODE: undefined, NODE_OPTIONS: '', CONTINUER_TEST_INSTANCE_DIR: directory }; + const children = [], completed = []; + const read = mode => { try { return JSON.parse(readFileSync(join(directory, mode + '.json'), 'utf8')); } catch { return {}; } }; + const waitFor = async (check, message) => { for (let i = 0; i < 70; i++) { if (check()) return; await delay(50); } throw Error(message); }; + try { + for (const mode of order) { + const child = spawn(executable, [fixture], { env: { ...env, CONTINUER_TEST_INSTANCE_MODE: mode }, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }); + let errors = ''; child.stderr.on('data', chunk => { errors = (errors + chunk).slice(-2000); }); + children.push(child); + const exit = new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(Error(read(mode).error || errors || `exit ${code}`))); }); + exit.catch(() => {}); completed.push(exit); + await waitFor(() => read(mode).ready, `No ready ${mode} instance: ${errors}`); + assert.equal(read(mode).acquired, true); + } + assert.notEqual(read('live').userData, read('demo').userData); + for (const mode of order) { + await promisify(execFile)(executable, [fixture, '--auto'], { env: { ...env, CONTINUER_TEST_INSTANCE_MODE: mode, CONTINUER_TEST_INSTANCE_PORT: '4319' }, windowsHide: true, timeout: 5000 }); + await waitFor(() => read(mode).secondInstances === 1, `No mismatched ${mode} request received`); + assert.equal(read(mode).hidden, true, 'Another service origin must not reveal this widget'); + await promisify(execFile)(executable, [fixture, '--auto'], { env: { ...env, CONTINUER_TEST_INSTANCE_MODE: mode }, windowsHide: true, timeout: 5000 }); + // The losing instance writes its lock result; wait for the owner to + // record the forwarded event before checking routing. + await waitFor(() => read(mode).secondInstances === 2, `No forwarded ${mode} request`); + assert.equal(read(mode).hidden, false); + } + assert.equal(read('live').identity.demo, false); assert.equal(read('demo').identity.demo, true); + writeFileSync(join(directory, order[0] + '.stop'), ''); await completed[0]; + assert.equal(children[1].exitCode, null, 'Stopping one mode must leave the other process alive'); + } finally { + for (const mode of order) writeFileSync(join(directory, mode + '.stop'), ''); + for (const child of children) if (child.exitCode === null) { + // Give the test-only stop marker a bounded chance before terminating + // just the process handle created by this test. + await Promise.race([completed[children.indexOf(child)].catch(() => {}), delay(1500)]); + if (child.exitCode === null) child.kill(); + } + await Promise.allSettled(completed); + assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }); +} diff --git a/tests/widget-smoke.test.js b/tests/widget-smoke.test.js index a9f2131..6f1a08e 100644 --- a/tests/widget-smoke.test.js +++ b/tests/widget-smoke.test.js @@ -9,15 +9,17 @@ import { once } from 'node:events'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import access from '../server/access.cjs'; +import widgetStateModule from '../server/widget-state.cjs'; import { pinnedFetch } from './fixtures/local-fetch.js'; const executable = fileURLToPath(new URL('../node_modules/electron/dist/electron.exe', import.meta.url)); for (const expanded of [false, true]) test(`hidden isolated widget is ready without loading unused views (expanded: ${expanded})`, { skip: process.platform !== 'win32' || !existsSync(executable), timeout: 40000 }, async () => { const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-widget-smoke-')); - writeFileSync(join(directory, 'widget-preferences.json'), JSON.stringify({ follow: false, hidden: true, expanded })); const trace = join(directory, 'requests.jsonl'), preload = new URL('./fixtures/widget-trace.mjs', import.meta.url); const probe = createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); + const widgetFiles = widgetStateModule.widgetState(directory, port, true); + writeFileSync(widgetFiles.preferences, JSON.stringify({ follow: false, hidden: true, expanded })); const env = { ...process.env, CONTINUER_DATA_DIR: directory, CONTINUER_DEMO: '1', CONTINUER_PORT: String(port), PORT: String(port), ELECTRON_RUN_AS_NODE: undefined }; const server = spawn(process.execPath, ['server/index.js', '--demo', '--observe-only'], { env: { ...env, CONTINUER_TEST_TRACE_FILE: trace, NODE_OPTIONS: `--import=${preload.href}` }, windowsHide: true, stdio: 'ignore' }); const secureFetch = pinnedFetch(directory, true); @@ -35,7 +37,7 @@ for (const expanded of [false, true]) test(`hidden isolated widget is ready with widget.stderr.on('data', chunk => { nativeErrors = (nativeErrors + chunk).slice(-2500); }); const exited = once(widget, 'exit'); let status; for (let i = 0; i < 120; i++) { - try { status = JSON.parse(readFileSync(join(directory, 'widget-status.json'), 'utf8')); } catch {} + try { status = JSON.parse(readFileSync(widgetFiles.status, 'utf8')); } catch {} if (status?.ready || widget.exitCode !== null) break; await delay(75); } @@ -55,7 +57,7 @@ for (const expanded of [false, true]) test(`hidden isolated widget is ready with assert.equal(requests.includes('/rhine-signal-loop.svg'), false, 'Hidden widgets should not load the animation'); spawn(executable, ['--disable-gpu', 'desktop/main.cjs', '--quit'], { env, windowsHide: true, stdio: 'ignore' }).unref(); await Promise.race([exited, delay(10000).then(() => { throw Error('Isolated widget did not quit'); })]); - const stopped = JSON.parse(readFileSync(join(directory, 'widget-status.json'), 'utf8')); + const stopped = JSON.parse(readFileSync(widgetFiles.status, 'utf8')); assert.equal(stopped.ready, false); assert.equal(stopped.exitReason, 'user-quit'); if (server.exitCode === null) await Promise.race([once(server, 'exit'), delay(3000).then(() => { if (server.exitCode === null) throw Error('Widget exit left its service running'); })]); assert.notEqual(server.exitCode, null); assert.equal(existsSync(join(directory, 'quit-demo.request')), true); From 201126b2fda46b81a33115d7c788a802d77872b2 Mon Sep 17 00:00:00 2001 From: empty610 Date: Sat, 31 Oct 2026 12:43:58 +0800 Subject: [PATCH 2/2] Fix CodeQL checks in the UI test fixture --- tests/fixtures/review4-ui.cjs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/review4-ui.cjs b/tests/fixtures/review4-ui.cjs index 3f5cea4..6b1f6c4 100644 --- a/tests/fixtures/review4-ui.cjs +++ b/tests/fixtures/review4-ui.cjs @@ -22,9 +22,7 @@ app.whenReady().then(async () => { server = createServer(async (req, res) => { const url = new URL(req.url, 'http://127.0.0.1'); if (url.pathname.startsWith('/fixture/')) { - const page = url.pathname.endsWith('page') ? 'index.html' : 'widget.html'; - const html = fs.readFileSync(path.join(root, 'public', page), 'utf8').replace(/]*>[\s\S]*?<\/script>/gi, ''); - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); return; + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(''); return; } if (url.pathname.startsWith('/api/')) { let body = ''; for await (const chunk of req) body += chunk; @@ -46,6 +44,22 @@ app.whenReady().then(async () => { webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); win.webContents.on('console-message', details => { if (details.level === 'error') errors.push(details.message); }); await win.loadURL(`http://127.0.0.1:${server.address().port}/fixture/${surface}`); + const html = fs.readFileSync(path.join(root, 'public', surface === 'page' ? 'index.html' : 'widget.html'), 'utf8'); + win.webContents.debugger.attach(); + const command = (method, params) => win.webContents.debugger.sendCommand(method, params); + const { result: globalObject } = await command('Runtime.evaluate', { expression: 'globalThis' }); + const { result, exceptionDetails } = await command('Runtime.callFunctionOn', { + objectId: globalObject.objectId, + functionDeclaration: `function (html) { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + parsed.querySelectorAll('script').forEach(script => script.remove()); + document.documentElement.replaceWith(document.importNode(parsed.documentElement, true)); + return document.querySelectorAll('script').length; + }`, + arguments: [{ value: html }], returnByValue: true, + }); + assert.equal(exceptionDetails, undefined); assert.equal(result.value, 0); + win.webContents.debugger.detach(); await win.webContents.executeJavaScript(`(async () => { Object.defineProperty(document,'hidden',{value:false,configurable:true}); window.createSource = () => { const source={close(){}}; window.testFeed=source; setTimeout(()=>source.onmessage({data:${JSON.stringify(JSON.stringify(state))}}),0); return source; };