284 lines
22 KiB
JavaScript
284 lines
22 KiB
JavaScript
import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs';
|
|
import assert from 'node:assert/strict';
|
|
import { fork } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, mkdtempSync, rmSync, lstatSync, unlinkSync, statSync } from 'node:fs';
|
|
import { join, basename } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { request as httpRequest } from 'node:http';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { fixture, tmp, withoutHostCredentials } from './helpers.mjs';
|
|
import { git } from '../../project/tests/prd-fixture.mjs';
|
|
import { FakeVikunja } from '../../tasks/src/fake.mjs';
|
|
import { SCOPES } from '../../tasks/tests/world.mjs'; // Scopes only, never AUTHORITY or world().
|
|
import { createRequestStore } from '../../bus/src/request-store.mjs';
|
|
import { startServer } from '../../webui/src/serve.mjs';
|
|
import { createSso, SESSION_COOKIE } from '../../webui/src/sso.mjs';
|
|
import { fakeOidc } from '../../webui/tests/helpers/fake-oidc.mjs';
|
|
import { browser } from '../../webui/tests/browser.mjs';
|
|
import { hostStatus } from '../src/host.mjs';
|
|
import { views } from '../../bus/src/views.mjs';
|
|
import { BusReadError } from '../../webui/src/bus.mjs';
|
|
|
|
const AGENT = fileURLToPath(new URL('./fixtures/request-agent.mjs', import.meta.url));
|
|
export const ORIGIN = 'https://lifecycle.console.test', HOST = new URL(ORIGIN).host;
|
|
const closeServer = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
|
|
|
|
export async function lifecycleFixture(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'life-')), original = JSON.parse(readFileSync(new URL('./fixtures/pomodoro-request.json', import.meta.url)));
|
|
let web, sso, op, service, child;
|
|
t.after(async () => {
|
|
if (process.env.MOSAIC_LIFECYCLE_EVIDENCE && existsSync(join(root, 'scenario.json'))) {
|
|
const dest = process.env.MOSAIC_LIFECYCLE_EVIDENCE; mkdirSync(dest, { recursive: true });
|
|
const file = join(dest, basename(root) + '.json');
|
|
writeFileSync(file, JSON.stringify(await evidence(), null, 2)+'\n', { flag: 'wx' });
|
|
t.diagnostic('lifecycle evidence: ' + file);
|
|
}
|
|
if (web) await closeServer(web);
|
|
sso?.close(); await op?.close();
|
|
if (child && child.exitCode === null && !child.signalCode) {
|
|
const gone = once(child, 'exit'); process.kill(-child.pid, 'SIGKILL'); await gone;
|
|
}
|
|
await service?.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const fake = new FakeVikunja(), owner = fake.user('owner'), bots = {};
|
|
for (let n = 2; n <= 9; n++) {
|
|
const role = ({ 3: 'pm', 4: 'cto', 5: 'coder', 6: 'reviewer', 9: 'sync' })[n];
|
|
const id = fake.user(role ?? 'unused-' + n, { owner }); if (role) bots[role] = id;
|
|
}
|
|
const board = fake.project('Factory', owner), productBoard = fake.project('Products', owner, { parent: board });
|
|
for (const id of [board, productBoard]) { fake.install(id); for (const [role, bot] of Object.entries(bots)) fake.share(id, bot, role === 'sync' ? 0 : 1); }
|
|
service = await fake.listen();
|
|
const f = fixture(root, 'demo', doc => {
|
|
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high' } };
|
|
for (const r of Object.values(doc.roles)) r.vars = { ...r.vars, harness: 'pi', model: 'glm-5.3-flash', thinking: 'high' };
|
|
doc.launch.instances = ['pm','cto','coder','reviewer'];
|
|
doc.launch.limits.models.zai['glm-5.3-flash'] = 8;
|
|
doc.vars['tracker.baseUrl'] = service.url;
|
|
for (const role of Object.keys(bots)) {
|
|
const target = role === 'sync' ? doc.tracker.sync : doc.roles[role];
|
|
target.credentials.vikunja = { env: 'FIXTURE_VK_' + role.toUpperCase(), expires: '2099-01-01' };
|
|
}
|
|
return doc;
|
|
});
|
|
withoutHostCredentials(f.env);
|
|
for (const role of Object.keys(bots)) f.env['FIXTURE_VK_' + role.toUpperCase()] = fake.token(bots[role], role === 'sync'
|
|
? { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } : role === 'pm' ? SCOPES.pm : SCOPES.worker);
|
|
f.env.REQUEST_FIXTURE_ROOT = root;
|
|
const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true });
|
|
writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': board, 'tracker.descendants': true } }));
|
|
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Isolated factory');
|
|
|
|
let next = 0, stopped = false, pausedPoint = null, killing = null, killResolve, hide = false;
|
|
const waiting = new Map();
|
|
const rpc = (op, args = {}) => new Promise((resolve, reject) => {
|
|
if (!child?.connected) return reject(new Error('fixture runtime not connected'));
|
|
const id = ++next, timer = setTimeout(() => { waiting.delete(id); reject(new Error('fixture RPC timeout: ' + op)); }, 120000);
|
|
waiting.set(id, { resolve, reject, timer }); child.send({ id, op, args });
|
|
});
|
|
const stopHard = async point => {
|
|
pausedPoint = point;
|
|
if (!child || child.exitCode !== null || child.signalCode) return;
|
|
const gone = once(child, 'exit');
|
|
process.kill(-child.pid, 'SIGKILL'); await gone;
|
|
killResolve?.(point); killResolve = null;
|
|
};
|
|
const rawFetch = fake.fetch;
|
|
fake.fetch = async (url, init) => {
|
|
const path = new URL(url).pathname;
|
|
if (hide && /\/projects\/\d+\/tasks$/.test(path) && new URL(url).searchParams.get('expand') !== 'comment_count' && (init?.method ?? 'GET') === 'GET') return new Response('{}', { status: 503 });
|
|
const result = await rawFetch(url, init);
|
|
if (killing === 'tracker-post' && init?.method === 'POST' && /\/projects\/\d+\/tasks$/.test(path)) {
|
|
killing = null; await stopHard('tracker-post');
|
|
}
|
|
return result;
|
|
};
|
|
async function boot() {
|
|
stopped = false;
|
|
child = fork(AGENT, ['--runtime'], { env: f.env, stdio: ['ignore','pipe','pipe','ipc'], detached: true });
|
|
const ready = new Promise((resolve, reject) => {
|
|
child.on('message', m => {
|
|
if (m.ready) resolve();
|
|
else if (m.checkpoint) stopHard(m.checkpoint).catch(reject);
|
|
else if (waiting.has(m.id)) {
|
|
const p = waiting.get(m.id); waiting.delete(m.id); clearTimeout(p.timer);
|
|
m.error ? p.reject(Object.assign(new Error(m.error), { code: m.error, detail: m.detail })) : p.resolve(m.value);
|
|
}
|
|
});
|
|
child.once('exit', (code, signal) => {
|
|
for (const p of waiting.values()) { clearTimeout(p.timer); p.reject(new Error('fixture runtime exited: ' + (signal ?? code))); } waiting.clear();
|
|
if (code || signal) reject(new Error('fixture runtime boot/exit: ' + (signal ?? code) + '\n' + readFileSync(join(root, 'runtime.log'), 'utf8')));
|
|
});
|
|
});
|
|
for (const stream of [child.stdout, child.stderr]) stream.on('data', bytes => appendFileSync(join(root, 'runtime.log'), bytes));
|
|
writeFileSync(join(root, 'runtime.log'), '', { flag: 'a' });
|
|
await ready;
|
|
}
|
|
op = await fakeOidc();
|
|
const issuer = op.issuer('fixture');
|
|
sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId,
|
|
clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] },
|
|
{ accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
|
|
const transport = async (verb, args, ctx) => {
|
|
try { return await rpc('console', { verb, args, subject: ctx.subject }); }
|
|
catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); }
|
|
};
|
|
const human = (verb, args = {}) => rpc('human', { verb, args });
|
|
const bus = views({ call: human });
|
|
const chatNames = { projects: 'project.list', threads: 'thread.list', read: 'thread.messages', sessions: 'session.list', instances: 'thread.instances',
|
|
createProject: 'project.create', createThread: 'thread.create', open: 'thread.open', send: 'thread.send' };
|
|
const chat = Object.fromEntries(Object.entries(chatNames).map(([name, verb]) => [name, args => human(verb, args)]));
|
|
web = await startServer({ port: 0, sso, send: args => human('message.send', args), bus, chat,
|
|
requestRead: transport, requestWrite: transport, decisionWrite: transport, log: () => {} });
|
|
const port = web.address().port;
|
|
function http(path, { method = 'GET', body, cookie, headers = {} } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const raw = body === undefined ? undefined : JSON.stringify(body);
|
|
if (method === 'POST') appendFileSync(join(root, 'owner-actions.jsonl'), JSON.stringify({ method, path })+'\n');
|
|
const req = httpRequest({ host: '127.0.0.1', port, path, method, headers: { host: HOST, ...(cookie ? { cookie } : {}),
|
|
...(raw === undefined ? {} : { 'content-length': Buffer.byteLength(raw), 'content-type': 'application/json', origin: ORIGIN, 'sec-fetch-site': 'same-origin' }), ...headers } }, res => {
|
|
const parts = []; res.on('data', part => parts.push(part)); res.on('end', () => {
|
|
const text = Buffer.concat(parts).toString();
|
|
resolve({ status: res.statusCode, headers: res.headers, body: (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text });
|
|
});
|
|
}); req.on('error', reject); req.setTimeout(120000, () => req.destroy(new Error('fixture HTTP timeout ' + path))); req.end(raw);
|
|
});
|
|
}
|
|
const login = await http('/auth/login');
|
|
const loginCookie = [login.headers['set-cookie']].flat()[0].split(';')[0];
|
|
const grant = op.grant(issuer, login.headers.location, { sub: 'owner' });
|
|
const query = new URLSearchParams({ code: grant.code, state: new URL(login.headers.location).searchParams.get('state') });
|
|
const signed = await http('/auth/callback?' + query, { cookie: loginCookie });
|
|
const cookie = [signed.headers['set-cookie']].flat().find(v => v.startsWith(SESSION_COOKIE + '=') && !v.includes('Max-Age=0')).split(';')[0];
|
|
const meta = () => JSON.parse(readFileSync(join(root, 'scenario.json')));
|
|
const checked = response => { assert.equal(response.status, 200, JSON.stringify(response.body) + (existsSync(join(root, 'console-errors.jsonl')) ? '\n' + readFileSync(join(root, 'console-errors.jsonl'), 'utf8') : '')); return response.body; };
|
|
const get = async path => checked(await http(path, { cookie }));
|
|
const post = async (path, body) => checked(await http(path, { cookie, method: 'POST', body }));
|
|
const current = () => get('/api/requests/' + meta().request);
|
|
const decide = async (id, choice) => {
|
|
const preview = await post(`/api/decisions/${id}/confirmation`, { choice });
|
|
return post(`/api/decisions/${id}/resolve`, { choice, confirmation: preview.confirmation });
|
|
};
|
|
const continueRun = () => rpc('drive');
|
|
await boot();
|
|
async function evidence() {
|
|
const m = meta(), db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
|
|
try {
|
|
const store = { all: (sql, ...a) => db.prepare(sql).all(...a), get: (sql, ...a) => db.prepare(sql).get(...a) };
|
|
const journal = createRequestStore(store), s = journal.readRequest('demo', m.request);
|
|
const history = Object.fromEntries(['events','request_events','request_effects','request_snapshots','request_snapshot_events'].map(table => [table, store.all(`SELECT * FROM ${table} ORDER BY ${table === 'request_snapshots' ? 'id' : 'seq'}`)]));
|
|
const events = history.events.map(e => ({ ...e, body: JSON.parse(e.body) }));
|
|
const tasks = events.filter(e => e.kind === 'task.created' && e.body.request === m.request);
|
|
const task = tasks[0]?.subject;
|
|
const sessions = readdirSync(join(f.dataRoot, 'launches/demo')).sort((a,b)=>statSync(join(f.dataRoot,'launches/demo',a,'session.json')).mtimeMs-statSync(join(f.dataRoot,'launches/demo',b,'session.json')).mtimeMs).map(id => JSON.parse(readFileSync(join(f.dataRoot, 'launches/demo', id, 'session.json'))));
|
|
const active = role => sessions.findLast(s => s.instance === role);
|
|
const artifact = s.delivery_evidence;
|
|
let download = '';
|
|
if (artifact) download = git(join(f.dataRoot, 'working/demo/timer.git'), 'show', `${artifact.commit}:${artifact.artifact.path}`);
|
|
const source = store.get('SELECT body FROM messages WHERE id=?', s.source_message);
|
|
const messages = store.all('SELECT * FROM messages ORDER BY seq');
|
|
const creates = fake.requests.filter(r => r.method === 'POST' && /\/projects\/\d+\/tasks$/.test(r.path)).length;
|
|
return { scenario: t.name, checkpoint: pausedPoint, request: m.request, state: s, history, tasks, events, source: { source_event: m.request, source_message: s.source_message, text: source.body }, original_quote: original, intent: journal.intent('demo', m.request).text,
|
|
provenance: original.provenance, coderTests: existsSync(join(root, 'coder-tests.log')) ? readFileSync(join(root, 'coder-tests.log'), 'utf8') : '', reviewerTests: existsSync(join(root, 'reviewer-tests.log')) ? readFileSync(join(root, 'reviewer-tests.log'), 'utf8') : '', lockRecovery: existsSync(join(root, 'lock-recovery.jsonl')) ? readFileSync(join(root, 'lock-recovery.jsonl'), 'utf8') : '', projects: existsSync(join(f.dataRoot, 'products/demo')) ? readdirSync(join(f.dataRoot, 'products/demo')) : [],
|
|
creates: Array.from({ length: creates }, (_, n) => fake.task(n + 1)).filter(t => t?.description.includes('request: ' + m.request)).length,
|
|
allCreates: creates, deliveries: history.request_effects.filter(e => e.request === m.request && e.kind === 'delivery' && e.stage === 'committed'),
|
|
activation: s.snapshot ? history.request_snapshot_events.find(e => e.snapshot === s.snapshot && e.kind === 'activated') : null,
|
|
snapshot: s.snapshot ? journal.snapshot(s.snapshot) : null, download, commit: task ? events.findLast(e => e.subject === task && e.body.action === 'git.push.working')?.body.commit : null,
|
|
coder: active('coder'), reviewer: active('reviewer'), manifests: Object.fromEntries(['pm','coder','reviewer'].map(role => [role, active(role)?.manifest ?? []])),
|
|
uncertain: events.filter(e => /uncertain/.test(JSON.stringify(e.body))),
|
|
userRelays: messages.filter(m => m.from_role === 'human' && /relay|forward this|run this command/i.test(m.body)).length,
|
|
userShellCommands: (existsSync(join(root, 'owner-actions.jsonl')) ? readFileSync(join(root, 'owner-actions.jsonl'), 'utf8') : '').trim().split('\n').filter(Boolean).map(JSON.parse).filter(a => /shell|command|task-create/.test(a.path)).length,
|
|
ownerSelections: events.filter(e => e.kind === 'human.input' && e.body.kind === 'answer' && e.body.decision === s.selection?.decision).length,
|
|
notification: messages.find(m => m.from_role === 'assistant' && m.to_role === 'human' && m.in_reply_to === s.source_message),
|
|
};
|
|
} finally { db.close(); }
|
|
}
|
|
return {
|
|
root, fake, http, get, post, current, decide, evidence,
|
|
start: () => rpc('start', { text: original.text }),
|
|
continue: continueRun,
|
|
async select(choice) { const s = await current(); await decide(s.selection.decision, choice); return continueRun(); },
|
|
async answer(text) { const s = await current(); await post(`/api/requests/${s.request}/answer`, { revision: s.revision, question: s.question.id, op: 'owner-answer', text }); return continueRun(); },
|
|
async approve(choice) { const s = await current(); await decide(s.wait.subject, choice); return continueRun(); },
|
|
async cancel() { const s = await current(); await post(`/api/requests/${s.request}/cancel`, { revision: s.revision, op: 'owner-cancel' }); return continueRun(); },
|
|
async mode(mode) { const s = await current(); await post(`/api/requests/${s.request}/mode`, { revision: s.revision, mode_revision: s.mode_revision, mode, op: 'owner-mode' }); return continueRun(); },
|
|
async attach({ revise }) {
|
|
await rpc('start', { text: revise ? original.text + ' Add an R keyboard shortcut to reset.' : original.text, attach: true });
|
|
let s = await current(); await decide(s.selection.decision, 'existing'); await continueRun();
|
|
s = await current(); const entries = await get(`/api/requests/${s.request}/projects`); assert.ok(entries.some(e => e.project === 'timer'));
|
|
await post(`/api/requests/${s.request}/attach`, { revision: s.revision, project: 'timer', op: 'owner-pick' });
|
|
await continueRun(); s = await current(); await decide(s.selection.decision, 'confirm'); return continueRun();
|
|
},
|
|
async kill(point) {
|
|
killing = point; const killed = new Promise(resolve => { killResolve = resolve; });
|
|
if (point !== 'tracker-post') await rpc('arm', { point });
|
|
const s = await current();
|
|
const work = decide(s.selection.decision, 'autonomous').then(continueRun).catch(() => {});
|
|
await killed; await work; assert.equal(pausedPoint, point);
|
|
},
|
|
async restart() {
|
|
if (child?.connected) { await rpc('close'); if (child.connected) child.disconnect(); }
|
|
const lock = join(f.dataRoot, 'bus/writer.lock');
|
|
if (existsSync(lock)) {
|
|
// Documented operator recovery, confined to this fixture's exact reaped child.
|
|
const status = hostStatus(f.dataRoot), before = lstatSync(lock);
|
|
assert.equal(status.writerLock.pid, child.pid); assert.equal(status.writerLock.live, false);
|
|
assert.equal(before.uid, process.getuid()); assert.ok(before.isFile());
|
|
const after = lstatSync(lock); assert.equal(after.ino, before.ino); assert.equal(after.dev, before.dev);
|
|
appendFileSync(join(root, 'lock-recovery.jsonl'), JSON.stringify({ pid: child.pid, verified_dead: true, inode: before.ino, action: 'remove-owned-stale-lock-and-socket' })+'\n');
|
|
unlinkSync(lock);
|
|
const socket = join(f.dataRoot, 'bus/broker.sock'); if (existsSync(socket)) { assert.ok(lstatSync(socket).isSocket()); unlinkSync(socket); }
|
|
}
|
|
await boot(); return continueRun();
|
|
},
|
|
hideTrackerOutcome() { hide = true; }, showTrackerOutcome() { hide = false; },
|
|
async download() { const s = await current(); return get(`/api/requests/${s.request}/artifact/${s.delivery}`); },
|
|
consoleBrowser: t => consoleBrowser(t, { port, cookie, current, source: () => meta(), decide, get, recordOwner: path => appendFileSync(join(root, 'owner-actions.jsonl'), JSON.stringify({ method: 'POST', path })+'\n') }),
|
|
};
|
|
}
|
|
|
|
async function consoleBrowser(t, f) {
|
|
const b = await browser(); t.after(() => b.close());
|
|
b.on('Fetch.requestPaused', ({ requestId, request: incoming }) => {
|
|
const url = new URL(incoming.url);
|
|
if (incoming.method === 'POST') f.recordOwner(url.pathname);
|
|
const req = httpRequest({ host: '127.0.0.1', port: f.port, path: url.pathname + url.search, method: incoming.method,
|
|
headers: { ...incoming.headers, host: HOST, cookie: f.cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => {
|
|
const parts = []; res.on('data', part => parts.push(part)); res.on('end', () => {
|
|
const responseHeaders = Object.entries(res.headers).filter(([name]) => !['connection','keep-alive','transfer-encoding'].includes(name))
|
|
.flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map(value => ({ name, value: String(value) })));
|
|
b.call('Fetch.fulfillRequest', { requestId, responseCode: res.statusCode, responseHeaders, body: Buffer.concat(parts).toString('base64') }).catch(() => {});
|
|
});
|
|
}); req.on('error', () => b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {})); req.end(incoming.postData);
|
|
});
|
|
await b.call('Fetch.enable', { patterns: [{ urlPattern: ORIGIN + '/*', requestStage: 'Request' }] });
|
|
const s = await f.current(); await b.navigate(`${ORIGIN}/#/inbox/${s.selection.decision}`);
|
|
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return;await new Promise(r=>setTimeout(r,50));}throw new Error('browser wait: '+${JSON.stringify(expr)})})()`);
|
|
const external = [];
|
|
return {
|
|
async choose(choice) {
|
|
await wait('document.querySelector("#decide-section [data-decide=option-0]")');
|
|
const index = ['autonomous','guided','existing'].indexOf(choice);
|
|
await b.evaluate(`document.querySelector('[data-decide=option-${index}]').click()`);
|
|
await wait('document.querySelector("[data-decide=confirm]")');
|
|
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');
|
|
await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
|
|
},
|
|
async offline(html) {
|
|
await b.call('Fetch.disable');
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__now=1000;Date.now=()=>window.__now;window.__ticks=[];window.setInterval=fn=>{window.__ticks.push(fn);return window.__ticks.length;};window.__sounds=0;window.AudioContext=class{currentTime=0;destination={};resume(){}createOscillator(){return{connect(){},frequency:{value:0},start(){window.__sounds++},stop(){}}}createGain(){return{connect(){},gain:{value:0}}}};` });
|
|
await b.call('Network.enable'); await b.call('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 });
|
|
b.on('Network.requestWillBeSent', ({ request }) => { if (/^https?:/.test(request.url)) external.push(request.url); });
|
|
const path = join(process.env.TMPDIR, 'downloaded-pomodoro-' + process.pid + '.html'); writeFileSync(path, html); await b.navigate('file://' + path);
|
|
},
|
|
clock: () => b.evaluate('document.querySelector("#clock").textContent'), phase: () => b.evaluate('document.querySelector("#phase").textContent'),
|
|
cues: () => b.evaluate('window.__sounds'), externalRequests: async () => external,
|
|
async start() { await b.call('Runtime.evaluate', { expression: 'document.querySelector("#start").click()', userGesture: true }); },
|
|
async pause() { await b.evaluate('document.querySelector("#pause").click()'); },
|
|
async reset() { await b.evaluate('document.querySelector("#reset").click()'); },
|
|
async advance(ms) { await b.evaluate(`window.__now+=${ms};window.__ticks.forEach(fn=>fn());true`); },
|
|
};
|
|
}
|