Skip to content
4 changes: 4 additions & 0 deletions tests/fixtures/shutdown-owned-launcher.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const { writeFileSync } = require('node:fs');

writeFileSync(process.env.CONTINUER_TEST_LAUNCHER_PID_FILE, JSON.stringify({ pid: process.pid }));
setInterval(() => {}, 1000);
8 changes: 8 additions & 0 deletions tests/fixtures/shutdown-owned-service.cjs
Original file line number Diff line number Diff line change
@@ -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);
14 changes: 14 additions & 0 deletions tests/fixtures/shutdown-passive.mjs
Original file line number Diff line number Diff line change
@@ -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 }));
52 changes: 52 additions & 0 deletions tests/fixtures/widget-animation.cjs
Original file line number Diff line number Diff line change
@@ -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); });
16 changes: 16 additions & 0 deletions tests/fixtures/widget-trace.mjs
Original file line number Diff line number Diff line change
@@ -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();
14 changes: 7 additions & 7 deletions tests/shutdown.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand All @@ -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 {
Expand Down
35 changes: 7 additions & 28 deletions tests/widget-animation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
const html = readFileSync(new URL('../public/widget.html', import.meta.url), 'utf8')
.replace('<link rel="stylesheet" href="/widget.css">', '<style>' + css + '</style>')
.replace('<body>', '<body class="collapsed">').replace('<div id="rhine-art" aria-hidden="true"></div>', '<div id="rhine-art" aria-hidden="true">' + art + '</div>');
.replace('<body>', '<body class="collapsed">').replace('<div id="rhine-art" aria-hidden="true"></div>', '<div id="rhine-art" aria-hidden="true">' + art + '</div>')
+ '<SCRIPT>globalThis.fixtureScriptRan = true</SCRIPT>';
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);
Expand Down
Loading
Loading