diff --git a/tests/fixtures/shutdown-owned-launcher.cjs b/tests/fixtures/shutdown-owned-launcher.cjs new file mode 100644 index 0000000..56fecb1 --- /dev/null +++ b/tests/fixtures/shutdown-owned-launcher.cjs @@ -0,0 +1,4 @@ +const { writeFileSync } = require('node:fs'); + +writeFileSync(process.env.CONTINUER_TEST_LAUNCHER_PID_FILE, JSON.stringify({ pid: process.pid })); +setInterval(() => {}, 1000); diff --git a/tests/fixtures/shutdown-owned-service.cjs b/tests/fixtures/shutdown-owned-service.cjs new file mode 100644 index 0000000..4ac400d --- /dev/null +++ b/tests/fixtures/shutdown-owned-service.cjs @@ -0,0 +1,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 }); + writeFileSync(process.argv[2], JSON.stringify({ pid: child.pid })); +} +setInterval(() => {}, 1000); diff --git a/tests/fixtures/shutdown-passive.mjs b/tests/fixtures/shutdown-passive.mjs new file mode 100644 index 0000000..fd17555 --- /dev/null +++ b/tests/fixtures/shutdown-passive.mjs @@ -0,0 +1,14 @@ +import localTls from '../../server/local-tls.cjs'; + +// Install the transport mock before runtime captures its configuration. +localTls.requestOptions = () => ({}); +let calls = 0; +localTls.fetchLocal = async () => { calls++; throw Error('fixture network failure'); }; +const runtime = await import('../../plugin/runtime.js'); +let blocked = 0; +for (const action of [() => runtime.ensureService(), () => runtime.openWidget({ automatic: true })]) { + try { await action(); } catch (error) { if (error.message.includes('已退出')) blocked++; } +} +const before = calls; +try { await runtime.openWidget(); } catch {} +console.log(JSON.stringify({ blocked, before, after: calls })); diff --git a/tests/fixtures/widget-animation.cjs b/tests/fixtures/widget-animation.cjs new file mode 100644 index 0000000..4f12bd0 --- /dev/null +++ b/tests/fixtures/widget-animation.cjs @@ -0,0 +1,52 @@ +const { app, BrowserWindow } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); + +const page = process.env.CONTINUER_TEST_ANIMATION_PAGE; +const output = process.env.CONTINUER_TEST_ANIMATION_OUTPUT; +const captureDir = process.env.CONTINUER_TEST_ANIMATION_CAPTURE; +const mark = stage => fs.writeFileSync(output, JSON.stringify({ stage })); +mark('starting'); +app.setPath('userData', path.join(path.dirname(output), 'profile')); +app.whenReady().then(async () => { + const win = new BrowserWindow({ width: 256, height: 256, show: false, frame: false, transparent: true, webPreferences: { offscreen: true, sandbox: true, contextIsolation: true, nodeIntegration: false } }); + mark('window-created'); + await win.loadURL('about:blank'); + win.webContents.debugger.attach(); + const command = (method, params) => win.webContents.debugger.sendCommand(method, params); + const { result: globalObject } = await command('Runtime.evaluate', { expression: 'globalThis' }); + // HTML is protocol data, never part of executable source. Parse it inertly + // and remove all script elements before importing the production markup. + 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.replaceChild(document.importNode(parsed.documentElement, true), document.documentElement); + return { scriptsRemaining: document.querySelectorAll('script').length, scriptRan: !!globalThis.fixtureScriptRan }; + }`, + arguments: [{ value: fs.readFileSync(page, 'utf8') }], returnByValue: true, + }); + if (exceptionDetails) throw Error(exceptionDetails.text); + mark('page-loaded'); + await command('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }); + mark('media-configured'); + await win.webContents.executeJavaScript("document.querySelector('#mini').classList.add('has-art');document.querySelector('#mini').dataset.motion='running';document.querySelector('#mini-quota').textContent='77';document.querySelector('#mini-reset').textContent='02:00:00';document.querySelector('#mini-reset').classList.remove('visually-empty');document.getAnimations().forEach(a=>a.pause());"); + mark('initialized'); + const phases = []; + for (const at of [0, 15000, 30000, 45000, 59000]) phases.push(await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=" + at + ");({at:" + at + ",opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName})")); + mark('phases-checked'); + await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=0);document.querySelector('#mini').classList.add('is-refreshing');globalThis.fade=document.querySelector('#quota-readout').getAnimations().find(a=>a.animationName==='quota-reload');fade.pause();"); + const duration = await win.webContents.executeJavaScript('fade.effect.getTiming().duration'); + const frames = []; + for (const at of [0, 250, 500, 750, 1000]) { + frames.push(await win.webContents.executeJavaScript("fade.currentTime=" + at + ";if(" + at + ">=500){document.querySelector('#mini-quota').textContent='39';document.querySelector('#mini-window').textContent='七天可用额度';document.querySelector('#mini-reset').textContent='1天 02:00:00'}({at:" + at + ",opacity:Number(getComputedStyle(document.querySelector('#quota-readout')).opacity)})")); + if (captureDir) { + fs.mkdirSync(captureDir, { recursive: true }); + await new Promise(resolve => setTimeout(resolve, 40)); + fs.writeFileSync(path.join(captureDir, 'quota-' + at + '.png'), (await win.webContents.capturePage()).toPNG()); + } + } + fs.writeFileSync(output, JSON.stringify({ ...result.value, phases, frames, duration })); + win.destroy(); app.quit(); +}).catch(error => { fs.writeFileSync(output, JSON.stringify({ error: error.stack })); app.exit(1); }); diff --git a/tests/fixtures/widget-trace.mjs b/tests/fixtures/widget-trace.mjs new file mode 100644 index 0000000..4623d6e --- /dev/null +++ b/tests/fixtures/widget-trace.mjs @@ -0,0 +1,16 @@ +import http from 'node:https'; +import { syncBuiltinESMExports } from 'node:module'; +import { appendFileSync } from 'node:fs'; + +const trace = process.env.CONTINUER_TEST_TRACE_FILE; +const routes = ['/api/health', '/api/state', '/api/events', '/api/threads', '/widget.html', '/widget.css', '/widget.js', '/rhine-signal-loop.svg']; +// The test observes route categories, not arbitrary request text or queries. +const routeLabel = url => routes.find(route => url === route || url.startsWith(route + '?')) || 'other'; +const original = http.createServer; +http.createServer = (options, listener) => original(options, (req, res) => { + const route = routeLabel(req.url); + appendFileSync(trace, JSON.stringify(route) + '\n'); + res.on('finish', () => appendFileSync(trace + '.responses', JSON.stringify({ path: route, status: res.statusCode, authenticated: !!req.headers.authorization }) + '\n')); + return listener(req, res); +}); +syncBuiltinESMExports(); diff --git a/tests/shutdown.test.js b/tests/shutdown.test.js index 2e7c856..bc18026 100644 --- a/tests/shutdown.test.js +++ b/tests/shutdown.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; -import { mkdtempSync, realpathSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, realpathSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -48,16 +48,16 @@ test('demo exit leaves live markers and the startup watcher alone', async t => { 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 code = `const localTls=(await import(${JSON.stringify(new URL('../server/local-tls.cjs', import.meta.url).href)})).default;localTls.requestOptions=()=>({});let calls=0;localTls.fetchLocal=async()=>{calls++;throw Error('fixture network failure')};const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)});let blocked=0;for(const action of [()=>runtime.ensureService(),()=>runtime.openWidget({automatic:true})])try{await action()}catch(error){if(error.message.includes('已退出'))blocked++}const before=calls;try{await runtime.openWidget()}catch{};console.log(JSON.stringify({blocked,before,after:calls}));`; - const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', code], { env: { ...process.env, CONTINUER_DATA_DIR: dataDir, CONTINUER_DEMO: '' }, windowsHide: true, encoding: 'utf8' })); + const probe = fileURLToPath(new URL('./fixtures/shutdown-passive.mjs', import.meta.url)); + const result = JSON.parse(execFileSync(process.execPath, [probe], { env: { ...process.env, CONTINUER_DATA_DIR: dataDir, CONTINUER_DEMO: '' }, windowsHide: true, encoding: 'utf8' })); assert.deepEqual(result, { blocked: 2, before: 0, after: 1 }); assert.equal(exitState.requested(dataDir), false); }); test('verified force cleanup terminates an owned service tree and refuses unrelated PIDs', { skip: process.platform !== 'win32', timeout: 20000 }, async t => { const dataDir = directory(t), root = join(dataDir, 'plugin'); mkdirSync(join(root, 'server'), { recursive: true }); const script = join(root, 'server/index.js'), childFile = join(dataDir, 'child.json'); - writeFileSync(script, `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore',windowsHide:true});require('node:fs').writeFileSync(${JSON.stringify(childFile)},JSON.stringify({pid:child.pid}));setInterval(()=>{},1000);`); - const server = spawn(process.execPath, [script], { windowsHide: true, stdio: 'ignore' }), exited = once(server, 'exit'); + copyFileSync(new URL('./fixtures/shutdown-owned-service.cjs', import.meta.url), script); + const server = spawn(process.execPath, [script, childFile], { windowsHide: true, stdio: 'ignore' }), exited = once(server, 'exit'); let childPid; t.after(async () => { if (server.exitCode === null) server.kill(); if (childPid && alive(childPid)) process.kill(childPid); }); const helper = fileURLToPath(new URL('../plugin/stop-owned.ps1', import.meta.url)); @@ -77,10 +77,10 @@ test('watcher cleanup recognizes quoted --watch arguments used by the startup bo const source = join(dataDir, 'watcher.cs'), executable = join(desktop, 'continuer-monitor.exe'); mkdirSync(join(root, 'plugin'), { recursive: true }); const launcherPidFile = join(dataDir, 'launcher.json'); - writeFileSync(join(root, 'plugin/open.js'), `require('node:fs').writeFileSync(${JSON.stringify(launcherPidFile)},JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);`); + copyFileSync(new URL('./fixtures/shutdown-owned-launcher.cjs', import.meta.url), join(root, 'plugin/open.js')); writeFileSync(source, 'class Watcher { static void Main(string[] args) { var info=new System.Diagnostics.ProcessStartInfo(args[1], "\\\"" + System.IO.Path.Combine(args[2],"plugin/open.js") + "\\\"");info.UseShellExecute=false;info.CreateNoWindow=true;System.Diagnostics.Process.Start(info);System.Threading.Thread.Sleep(-1); } }'); execFileSync(join(process.env.SystemRoot, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), ['/nologo', '/target:winexe', '/out:' + executable, source], { windowsHide: true }); - const watcher = spawn(executable, ['"--watch"', '"' + process.execPath + '"', '"' + root + '"', '"' + dataDir + '"'], { windowsHide: true, windowsVerbatimArguments: true, stdio: 'ignore' }); + const watcher = spawn(executable, ['"--watch"', '"' + process.execPath + '"', '"' + root + '"', '"' + dataDir + '"'], { env: { ...process.env, CONTINUER_TEST_LAUNCHER_PID_FILE: launcherPidFile }, windowsHide: true, windowsVerbatimArguments: true, stdio: 'ignore' }); const exited = once(watcher, 'exit'); writeFileSync(join(dataDir, 'autostart-status.json'), JSON.stringify({ pid: watcher.pid, pluginRoot: root })); let launcherPid; try { diff --git a/tests/widget-animation.test.js b/tests/widget-animation.test.js index 11ba159..d767495 100644 --- a/tests/widget-animation.test.js +++ b/tests/widget-animation.test.js @@ -2,7 +2,7 @@ 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, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, realpathSync, existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -14,38 +14,17 @@ test('real quota CSS stays visible across artwork phases and fades smoothly over const captureDir = process.env.CONTINUER_CAPTURE_DIR ? resolve(process.env.CONTINUER_CAPTURE_DIR) : null; const css = readFileSync(new URL('../public/widget.css', import.meta.url), 'utf8'); const art = readFileSync(new URL('../public/rhine-signal-loop.svg', import.meta.url), 'utf8'); - const html = readFileSync(new URL('../public/widget.html', import.meta.url), 'utf8').replace(/]*>[\s\S]*?<\/script>/g, '') + const html = readFileSync(new URL('../public/widget.html', import.meta.url), 'utf8') .replace('', '') - .replace('', '').replace('', ''); + .replace('', '').replace('', '') + + ''; writeFileSync(page, html); - writeFileSync(probe, ` - const {app,BrowserWindow}=require('electron');const fs=require('node:fs');const path=require('node:path'); - const mark=stage=>fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({stage}));mark('starting'); - app.setPath('userData',${JSON.stringify(join(dir, 'profile'))}); - app.whenReady().then(async()=>{ - const win=new BrowserWindow({width:256,height:256,show:false,frame:false,transparent:true,webPreferences:{offscreen:true,sandbox:true,contextIsolation:true,nodeIntegration:false}}); - mark('window-created'); - await win.loadFile(${JSON.stringify(page)}); - mark('page-loaded'); - win.webContents.debugger.attach();await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'no-preference'}]}); - mark('media-configured'); - await win.webContents.executeJavaScript("document.querySelector('#mini').classList.add('has-art');document.querySelector('#mini').dataset.motion='running';document.querySelector('#mini-quota').textContent='77';document.querySelector('#mini-reset').textContent='02:00:00';document.querySelector('#mini-reset').classList.remove('visually-empty');document.getAnimations().forEach(a=>a.pause());"); - mark('initialized'); - const phases=[];for(const at of [0,15000,30000,45000,59000])phases.push(await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime="+at+");({at:"+at+",opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName})")); - mark('phases-checked'); - await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=0);document.querySelector('#mini').classList.add('is-refreshing');globalThis.fade=document.querySelector('#quota-readout').getAnimations().find(a=>a.animationName==='quota-reload');fade.pause();"); - const duration=await win.webContents.executeJavaScript('fade.effect.getTiming().duration'); - const frames=[];for(const at of [0,250,500,750,1000]){ - frames.push(await win.webContents.executeJavaScript("fade.currentTime="+at+";if("+at+">=500){document.querySelector('#mini-quota').textContent='39';document.querySelector('#mini-window').textContent='七天可用额度';document.querySelector('#mini-reset').textContent='1天 02:00:00'}({at:"+at+",opacity:Number(getComputedStyle(document.querySelector('#quota-readout')).opacity)})")); - if(${JSON.stringify(captureDir)}){fs.mkdirSync(${JSON.stringify(captureDir)},{recursive:true});await new Promise(r=>setTimeout(r,40));fs.writeFileSync(path.join(${JSON.stringify(captureDir)},'quota-'+at+'.png'),(await win.webContents.capturePage()).toPNG());} - } - fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({phases,frames,duration}));win.destroy();app.quit(); - }).catch(error=>{fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({error:error.stack}));app.exit(1)}); - `); + copyFileSync(new URL('./fixtures/widget-animation.cjs', import.meta.url), probe); try { - try { await execute(electron, ['--disable-gpu', probe], { env: { ...process.env, NODE_OPTIONS: '', ELECTRON_RUN_AS_NODE: undefined }, timeout: 20000, windowsHide: true }); } + try { await execute(electron, ['--disable-gpu', probe], { env: { ...process.env, NODE_OPTIONS: '', ELECTRON_RUN_AS_NODE: undefined, CONTINUER_TEST_ANIMATION_PAGE: page, CONTINUER_TEST_ANIMATION_OUTPUT: output, CONTINUER_TEST_ANIMATION_CAPTURE: captureDir || '' }, timeout: 20000, windowsHide: true }); } catch (error) { error.message += existsSync(output) ? '\nProbe: ' + readFileSync(output, 'utf8') : '\nProbe did not start'; throw error; } const result = JSON.parse(readFileSync(output, 'utf8')); assert.equal(result.error, undefined); + assert.equal(result.scriptsRemaining, 0); assert.equal(result.scriptRan, false); assert.equal(result.duration, 1000); for (const phase of result.phases) { assert.equal(phase.opacity, 1); assert.equal(phase.animation, 'none'); } assert.equal(result.frames[0].opacity, 1); assert.equal(result.frames[2].opacity, 0); assert.equal(result.frames[4].opacity, 1); diff --git a/tests/widget-smoke.test.js b/tests/widget-smoke.test.js index f200e20..a9f2131 100644 --- a/tests/widget-smoke.test.js +++ b/tests/widget-smoke.test.js @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'; import { createServer } from 'node:net'; import { once } from 'node:events'; import { setTimeout as delay } from 'node:timers/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import access from '../server/access.cjs'; import { pinnedFetch } from './fixtures/local-fetch.js'; @@ -15,13 +15,11 @@ const executable = fileURLToPath(new URL('../node_modules/electron/dist/electron 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 = join(directory, 'trace.mjs'); - writeFileSync(preload, `import http from 'node:https';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs'; - const original=http.createServer;http.createServer=(options,listener)=>original(options,(req,res)=>{appendFileSync(${JSON.stringify(trace)},JSON.stringify(req.url)+'\\n');res.on('finish',()=>appendFileSync(${JSON.stringify(trace + '.responses')},JSON.stringify({path:req.url,status:res.statusCode,authenticated:!!req.headers.authorization})+'\\n'));return listener(req,res)});syncBuiltinESMExports();`); + 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 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, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, windowsHide: true, stdio: 'ignore' }); + 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); const authenticatedFetch = (url, options = {}) => secureFetch(url, { ...options, headers: { ...options.headers, Authorization: 'Bearer ' + access.readToken(directory, true) } }); let widget; @@ -52,6 +50,7 @@ for (const expanded of [false, true]) test(`hidden isolated widget is ready with for (let i = 0; clients && i < 25; i++) { await delay(50); clients = (await (await authenticatedFetch(`https://127.0.0.1:${port}/api/health`)).json()).resources.sseClients; } assert.equal(clients, 0, 'Hidden widget must release its bootstrap stream'); const requests = readFileSync(trace, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + assert.ok(requests.includes('/api/health'), 'The trace must record the known health route'); assert.equal(requests.includes('/api/threads'), false, 'Hidden widgets should not load session lists'); 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();