open-standards/wai/quantum-studio/test/studio.test.mjs
Transaction Science a1bf417590 wai-quantum: a program's history, attributed, and the Studio's Versions
Feature 6 of the co-design report: every version of a program, who made
each change and how, and whose hand each line and gate is in, sealed
into the run's receipt.

- engine::versions:
  - versions in a chain, each naming its parents (none, one, or two for
    a merge), who made it (the person, the assistant, an outside agent,
    the engine, or where the program came from) and how (an edit, a
    proposal reviewed change by change, a merge);
  - a version's digest covers its parents' digests, and the recording
    host's key seals it; verify checks every digest, seal and review;
  - changes and apply: a line diff (Myers), so a proposal is taken
    change by change, and the version keeps the whole proposal;
  - blame, authorship and gate_hands: a line keeps its author until
    changed; a model's or an agent's line the person edits is in both
    hands; an undo keeps the earlier authors; a merge keeps each side's;
    gates follow their lines, at a generator's inputs;
  - attest and check_job: a job's request carries the account, the
    receipt binds it, and it is recomputed from the history;
  - call: the history by name with JSON arguments, for the page and the
    desktop app.
- The command line: history start | commit | changes | propose | merge |
  log | blame | verify; run and submit --history; verify --history.
- MCP: history_commit, history_changes, history_blame and history_log.
  An agent's edit is recorded as an outside agent's, under the name its
  client gave in initialize, whatever it says of itself.
- The Studio's Versions pane:
  - what the reader types is theirs, recorded when they pause;
  - every proposal (the assistant's program, an alternative, a generator
    from the Workbench) is offered change by change while the editor
    waits; a fix is the engine's;
  - lines of work in lanes: an older version opened and edited starts
    its own, and "Bring in" merges one change by change;
  - "Who wrote it" marks lines in the gutter and gates on the diagram;
  - the run's receipt binds the account, which the result recomputes;
  - kept in the browser, downloaded for the command line;
  - window.quantumStudio.propose for an embedding page.
- Fixes found by the Studio's test: a run shows it is running before
  the account is made, and the draft is kept as the page goes.
- CI: wai-web's native engine tests count 8, the history's included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:02:58 -04:00

550 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The Studio in the installed browser, as a reader uses it:
//
// node build.mjs --pkg PATH/TO/ENGINE/PKG && node test/studio.test.mjs
//
// Serves dist/ (with the engine package in dist/pkg/); the page's index.html names the
// live job service, https://wai.transaction.science/quantum-jobs, which the service checks use.
// - the first example loads; the program is linted, drawn and planned as it is typed;
// - it runs in this browser: the stabilizer tableau, only 00 and 11, ⟨Z0 Z1⟩ = 1, the
// receipt checked here and signed by this browser's key;
// - an error in the program is marked on its line;
// - a rotation runs on the state vector, ⟨Z0⟩ = cos 0.3;
// - the same Bell job on the service gives the counts run here, shot for shot, and its
// receipt checks here as the service's (left out with STUDIO_SKIP_SERVICE=1);
// - Ctrl+Enter in the editor runs; the program and the history survive a reload;
// - Versions: each change recorded with who made it; a proposal taken change by
// change; whose hand each line and gate is in; the receipt binding it; a line
// of work from an older version merged back; kept and downloaded;
// - the critique: its measures as the program changes and their change since the last
// run; a smell named with its lines, flagged on the diagram, shown, and fixed by the
// engine's rewrite, checked the same circuit;
// - Alternatives: Improve offers the engine's rewrite checked equivalent, plotted and
// applied; Explore without a model says it needs one;
// - the classical-shortcut lens: a Clifford ring with a closing rotation layer priced by
// the cut-rank sum, a Clifford circuit easy at any width, a contraction searched only
// when thorough, and a check marked stale once the program changes;
// - predict first: the form stops a run until the reader predicts, refuses an outcome of
// the wrong width, and sets the prediction against the counts;
// - the assistant, against a scripted model server: a tool call run on the engine, a
// proposal the engine cannot read sent back with its reason, the corrected one checked
// and run before it is offered, and Apply; it asks questions first by default and speaks
// only when asked; offering a hint after refused runs only when the reader allows it,
// and never asking the model itself; Improve and Explore with the model, each candidate
// read, measured and compared by the engine, a rewrite that is not the same circuit
// left out with its reason;
// - generators: a program with an integer input shows it under the editor, is linted and
// drawn at its value, and runs there; the Workbench starts from it, judges a hundred
// blind edits, keeps the reader's log-depth tree as the cheapest, opens it in the
// editor, and comes back with the page; with the model, its generators are judged at
// every size, a wrong one and one with a gate the specification forbids left out, and
// it is told the specification and shown the cheapest found;
// - no console error and no failed request; at a phone's width nothing scrolls sideways.
// Exit 0 iff every check passes.
import { createServer } from 'node:http';
import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, extname, join, normalize, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const { chromium } = (await import(process.env.WAI_E2E_DRIVER ?? 'playwright')).default;
const DIST = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
const SHOTS = resolve(dirname(fileURLToPath(import.meta.url)), 'screenshots');
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.wasm': 'application/wasm', '.json': 'application/json' };
const server = createServer((req, res) => {
let file = normalize(join(DIST, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
if (!file.startsWith(DIST)) { res.writeHead(403).end(); return; }
if (existsSync(file) && statSync(file).isDirectory()) file = join(file, 'index.html');
if (!existsSync(file)) { res.writeHead(404).end('not found'); return; }
res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' });
res.end(readFileSync(file));
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const ORIGIN = `http://127.0.0.1:${server.address().port}`;
let fails = 0;
const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails += 1; };
const LONG = 120_000;
const browser = await chromium.launch({ channel: process.env.WAI_E2E_CHANNEL ?? 'chrome', headless: true });
async function open(viewport = { width: 1400, height: 950 }) {
const ctx = await browser.newContext({ viewport, colorScheme: 'dark' });
const page = await ctx.newPage();
const seen = { errors: [], failed: [] };
page.on('console', (m) => { if (m.type() === 'error') seen.errors.push(m.text()); });
page.on('pageerror', (e) => seen.errors.push(String(e)));
page.on('requestfailed', (r) => seen.failed.push(`${r.url()} (${r.failure()?.errorText})`));
page.on('response', (r) => { if (r.status() >= 400) seen.failed.push(`${r.url()} (${r.status()})`); });
await page.goto(ORIGIN + '/', { waitUntil: 'domcontentloaded' });
return { ctx, page, seen };
}
const done = (page) => page.waitForFunction(() => !/^Running|^On the service/.test(document.querySelector('.qs-status')?.textContent ?? '') && document.querySelector('#qs-run') && !document.querySelector('#qs-run').disabled, null, { timeout: LONG });
const verified = (page) => page.waitForFunction(() => /verifies|does not verify|not checked/.test(document.querySelector('#qs-verified')?.textContent ?? ''), null, { timeout: LONG });
const counts = (page) => page.$$eval('.qs-bar', (bs) => bs.map((b) => [b.querySelector('.qs-key').textContent, b.querySelector('.qs-n').textContent]));
const { ctx, page, seen } = await open();
await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG });
check((await page.textContent('#qs-lint')) === '2 qubits · 2 gates', `the first example is linted as the engine reads it (${await page.textContent('#qs-lint')})`);
await page.waitForFunction(() => document.querySelector('#qs-circuit')?.textContent.includes('⊕'), null, { timeout: LONG });
const drawing = await page.textContent('#qs-circuit');
check(/q0: .*H.*●/.test(drawing) && /q1: .*⊕/.test(drawing), 'the circuit is drawn as the program is typed');
const svg = await page.$eval('#qs-diagram svg', (g) => ({ label: g.getAttribute('aria-label'), rings: g.querySelectorAll('.qc-ring').length, dots: g.querySelectorAll('.qc-dot').length, joins: g.querySelectorAll('.qc-join').length, meters: g.querySelectorAll('.qc-measure').length }));
check(svg.rings === 1 && svg.dots === 1 && svg.joins === 1 && svg.meters === 2 && /2 qubits/.test(svg.label), `the circuit is drawn as a diagram with a text alternative (${JSON.stringify(svg)})`);
await page.click('#qs-astext');
check(await page.isVisible('#qs-circuit') && await page.isHidden('#qs-diagram'), '“Text” shows the circuit as text to copy');
await page.click('#qs-astext');
await page.waitForFunction(() => /Stabilizer tableau/.test(document.querySelector('#qs-plan')?.textContent ?? ''), null, { timeout: LONG });
check(true, `the plan is shown before running (${await page.textContent('#qs-plan')})`);
await page.click('#qs-run');
await done(page);
await verified(page);
check((await page.textContent('.qs-method')) === 'Stabilizer tableau', 'run in this browser: the stabilizer tableau');
const here = await counts(page);
check(here.length === 2 && here.every(([k]) => k === '00' || k === '11'), `only 00 and 11 (${JSON.stringify(here)})`);
const zz = await page.$$eval('.qs-table tbody tr', (rs) => rs.map((r) => r.textContent));
check(zz.some((t) => t.startsWith('⟨Z0 Z1⟩') && t.includes('1.000000')), `⟨Z0 Z1⟩ = 1 (${zz.slice(0, 2).join(' | ')})`);
check((await page.textContent('#qs-verified')).startsWith('✓ verifies'), `the receipt verifies, checked here (${await page.textContent('#qs-verified')})`);
check((await page.textContent('#qs-signed-by')).startsWith('this browser’s key did:key:z6Mk'), `signed by this browser's key (${await page.textContent('#qs-signed-by')})`);
check((await page.$$('.qs-hitem')).length === 1, 'the run is in the history');
// What it looks like, for a reader of the change: the dark scheme and the light.
mkdirSync(SHOTS, { recursive: true });
await page.screenshot({ path: join(SHOTS, 'studio-dark.png'), fullPage: true });
await page.emulateMedia({ colorScheme: 'light' });
await page.screenshot({ path: join(SHOTS, 'studio-light.png'), fullPage: true });
await page.emulateMedia({ colorScheme: 'dark' });
// An error is marked on its line.
await page.click('.qs-editor .cm-content');
await page.keyboard.press('ControlOrMeta+End');
await page.keyboard.type('\nfoo q[0];');
await page.waitForFunction(() => document.querySelector('#qs-lint')?.textContent === 'problem', null, { timeout: LONG });
await page.waitForSelector('.cm-lintRange-error', { timeout: LONG });
check(true, 'an unknown gate is marked in the editor');
for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace');
await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG });
// A rotation: any angle, on the state vector.
await page.selectOption('#qs-example', 'rotation');
await page.waitForFunction(() => /State vector/.test(document.querySelector('#qs-plan')?.textContent ?? ''), null, { timeout: LONG });
await page.click('#qs-run');
await done(page);
const z = await page.$$eval('.qs-table tbody tr', (rs) => rs.map((r) => r.textContent));
check((await page.textContent('.qs-method')) === 'State vector, double precision' && z.some((t) => t.startsWith('⟨Z0⟩') && t.includes(Math.cos(0.3).toFixed(6))), `rx(0.3) on the state vector, ⟨Z0⟩ = cos 0.3 (${z[0]})`);
// The same Bell job on the service, and here: the same counts.
// (STUDIO_SKIP_SERVICE=1 leaves it out, where a build may not reach the service.)
if (process.env.STUDIO_SKIP_SERVICE) {
console.log('SKIP the service checks (STUDIO_SKIP_SERVICE)');
} else {
await page.selectOption('#qs-example', 'bell');
await page.click('#qs-run'); await done(page); await verified(page);
const local = await counts(page);
await page.click('.qs-seg[data-where=service]');
await page.click('#qs-run'); await done(page); await verified(page);
const remote = await counts(page);
check(JSON.stringify(remote) === JSON.stringify(local), `the service gives the counts run here, shot for shot (${JSON.stringify(remote)})`);
check((await page.textContent('#qs-signed-by')).startsWith('the service’s key') && (await page.textContent('#qs-verified')).startsWith('✓ verifies'), `the service's receipt checks here, as the service's (${await page.textContent('#qs-signed-by')})`);
await page.click('.qs-seg[data-where=local]');
}
// Ctrl+Enter runs; the program and the history survive a reload. (A job run again
// unchanged gives the same signed result, which the history keeps once; so this one
// asks for other shots.)
const before = (await page.$$('.qs-hitem')).length;
await page.fill('#qs-shots', '321');
await page.click('.qs-editor .cm-content');
await page.keyboard.press('ControlOrMeta+Enter');
await done(page);
check((await page.$$('.qs-hitem')).length === before + 1, 'Ctrl+Enter in the editor runs the program');
const program = await page.evaluate(() => window.quantumStudio.view.state.doc.toString());
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG });
check((await page.evaluate(() => window.quantumStudio.view.state.doc.toString())) === program, 'the program survives a reload');
await page.waitForFunction((n) => document.querySelectorAll('.qs-hitem').length >= n, before + 1, { timeout: LONG });
check(true, 'the history survives a reload');
// The critique, as the program changes.
const doc = () => page.evaluate(() => window.quantumStudio.view.state.doc.toString());
const setProgram = async (q) => { await page.evaluate((q) => window.quantumStudio.fill({ qasm: q }), q); await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, q); };
const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textContent).catch(() => '');
{
await page.selectOption('#qs-example', 'bell');
await page.waitForFunction(() => /2 gates/.test(document.querySelector('#qs-measures')?.textContent ?? ''), null, { timeout: LONG });
const m = await page.textContent('#qs-measures');
check(/2 qubits/.test(m) && /1 two-qubit/.test(m) && await page.isVisible('#qs-clifford') && /fidelity, modelled/.test(m) && (await page.$$('#qs-smells li')).length === 0,
`the critique gives the Bell pair's measures, that it is Clifford, a modelled fidelity, and no smells (${m})`);
const SMELLY = 'OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nx q[1];\nx q[1];\ncx q[0], q[1];\nc = measure q;\n';
await setProgram(SMELLY);
await page.waitForSelector('.qs-smell[data-kind=cancels]', { timeout: LONG });
const smell = await page.textContent('.qs-smell[data-kind=cancels] .qs-smell-text');
check(smell === 'x on qubit 1 twice in a row (lines 5 and 6): together they do nothing', `a smell is named with its lines (${smell})`);
check((await page.$$('#qs-diagram .qc-item.qc-flag')).length === 2, 'its two gates are flagged on the diagram');
await page.hover('.qs-smell[data-kind=cancels]');
check((await page.$$('#qs-diagram .qc-item.qc-smell')).length === 2 && (await page.$$('#qs-diagram .qc-wire.qc-smell')).length === 0, 'pointing at the smell marks its gates, and not their wires');
await page.click('.qs-smell[data-kind=cancels] button >> text=Show');
const sel = await page.evaluate(() => { const v = window.quantumStudio.view, d = v.state.doc, r = v.state.selection.main; return [r.from === d.line(5).from, r.to === d.line(6).to]; });
check(sel[0] && sel[1], 'Show selects its lines in the editor');
await page.click('#qs-run'); await done(page);
await page.click('.qs-smell[data-kind=cancels] .qs-fix');
await page.waitForFunction(() => !/x q\[1\];/.test(window.quantumStudio.view.state.doc.toString()) && /checked the result/.test(document.querySelector('#qs-shows')?.textContent ?? ''), null, { timeout: LONG });
const shows = await page.textContent('#qs-shows');
check(/^Fixed: x on qubit 1/.test(shows) && /same tableau/.test(shows), `Fix applies the engine's rewrite, checked the same circuit (${shows})`);
await page.waitForFunction(() => /−2/.test(document.querySelector('#qs-measures [data-k=gates]')?.textContent ?? ''), null, { timeout: LONG });
check(true, `the measures show their change since the last run (${await chip('gates')})`);
await page.click('.qs-editor .cm-content');
await page.keyboard.press('ControlOrMeta+z');
await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, SMELLY, { timeout: LONG });
check(true, 'Undo brings the fixed lines back');
await setProgram('OPENQASM 3.0;\nqubit[3] q;\nbit[1] c;\nh q[0];\nc[0] = measure q[0];\n');
await page.waitForSelector('.qs-smell[data-kind=idle]', { timeout: LONG });
check((await page.$$('#qs-diagram .qc-wire.qc-flag')).length === 2 && !(await page.$('.qs-smell[data-kind=idle] .qs-fix')), 'qubits nothing uses are flagged on their wires, with no Fix');
}
// Alternatives: Improve, without a model; Explore says it needs one.
{
const MERGE = 'OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nrz(0.25) q[0];\nrz(0.5) q[0];\nc[0] = measure q[0];\n';
await setProgram(MERGE);
await page.click('#qs-improve');
await page.waitForSelector('.qs-cand', { timeout: LONG });
const card = await page.textContent('.qs-cand');
check(/the engine/.test(card) && /same circuit · on every basis state/.test(card) && /2 gates/.test(card), `Improve offers the engine's rewrite, checked on every basis state (${card.slice(0, 160)})`);
check(await page.isVisible('#qs-atlas') && (await page.$$('#qs-atlas .qs-dot')).length === (await page.$$('.qs-cand')).length && (await page.$$('#qs-atlas .qs-ring')).length === 1, 'the atlas plots the draft and each alternative');
check(/Connect a model/.test(await page.textContent('#qs-alt-body')), 'without a model, Improve says the engine’s are the only ones');
check((await doc()) === MERGE, 'nothing changes the program until an alternative is applied');
await page.click('.qs-cand .qs-apply');
await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG });
check((await doc()) === MERGE && /A proposal from the engine \(Alternatives\)/.test(await page.textContent('#qs-review')), 'Apply offers the alternative as the engine’s proposal, to take change by change');
await page.click('#qs-review-apply');
await page.waitForFunction(() => /rz\(0\.25 \+ 0\.5\) q\[0\];/.test(window.quantumStudio.view.state.doc.toString()), null, { timeout: LONG });
check(true, 'taking it puts the alternative in the editor');
await page.click('#qs-explore');
await page.waitForSelector('#qs-alt-nomodel', { timeout: LONG });
check(true, 'Explore without a model says it needs one');
}
// Versions: each change and who made it; a proposal taken change by change;
// whose hand each line and gate is in; the run's receipt binding the count; a
// line of work from an older version, brought back in; and all of it kept.
{
const here = () => page.textContent('#qs-vs-list li.here');
const record = () => page.evaluate(() => window.quantumStudio.versions.record());
const BASE = 'OPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n';
await setProgram(BASE);
await record();
check(/edited by you/.test(await here()), `what the reader types is recorded as theirs (${await here()})`);
const mine = await page.$eval('#qs-vs-list li.here', (l) => l.dataset.id);
const PROPOSED = 'OPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[1];\ncx q[0], q[1];\ncx q[1], q[2];\nc = measure q;\n';
await page.evaluate((q) => { window.quantumStudio.propose(q, { kind: 'assistant', name: 'scripted' }, 'entangle the third qubit'); }, PROPOSED);
await page.waitForSelector('#qs-review:not([hidden]) #qs-review-1', { timeout: LONG });
const offered = await page.$$eval('.qs-review-list > li', (ls) => ls.map((l) => l.textContent));
check(offered.length === 2 && /h q\[0\]/.test(offered[0]) && /\+ cx q\[1\], q\[2\]/.test(offered[1]) && await page.evaluate(() => window.quantumStudio.view.state.readOnly),
`a proposal is shown change by change, and the program waits (${offered.map((t) => t.replace(/\s+/g, ' ')).join(' | ')})`);
await page.uncheck('#qs-review-0');
check((await page.textContent('#qs-review-apply')) === 'Take 1 of 2', 'the reader takes the changes they want');
await page.click('#qs-review-apply');
const TAKEN = BASE.replace('cx q[0], q[1];\n', 'cx q[0], q[1];\ncx q[1], q[2];\n');
await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q && !window.quantumStudio.view.state.readOnly, TAKEN, { timeout: LONG });
check(/proposed by the assistant \(scripted\)/.test(await here()) && /1 of 2 taken/.test(await here()), `only the change taken reaches the program, recorded as the assistant's (${await here()})`);
check(/1 of 2 changes taken; the rest were rejected/.test(await page.textContent('#qs-vs-detail')), 'the version keeps what was rejected');
await page.check('#qs-vs-hands');
await page.waitForFunction(() => document.querySelectorAll('.qs-hand-gutter .qs-hand:not(.qs-hand-spacer)').length === 7, null, { timeout: LONG });
const gutter = await page.$$eval('.qs-hand-gutter .qs-hand:not(.qs-hand-spacer)', (ms) => ms.map((m) => m.className.replace('qs-hand qs-hand-', '')));
await page.waitForFunction(() => document.querySelectorAll('#qs-diagram .qc-item.qc-by-assistant').length === 1, null, { timeout: LONG });
check(gutter[5] === 'assistant' && gutter.filter((h) => h === 'assistant').length === 1 && gutter.slice(1, 3).every((h) => h === 'person'),
`“Who wrote it” marks each line in the gutter and each gate on the diagram (${gutter.join(', ')})`);
// The reader edits the model's line: in both hands.
await setProgram(TAKEN.replace('cx q[1], q[2];', 'cx q[0], q[2];'));
await record();
await page.waitForFunction(() => document.querySelectorAll('.qs-hand-gutter .qs-hand-assistant_then_person').length === 1, null, { timeout: LONG });
check(/the assistant, then you \(scripted\): 1 gate, 1 line/.test(await page.textContent('#qs-vs-share')), `a model's line the reader edits is in both hands (${await page.textContent('#qs-vs-share')})`);
await page.click('#qs-run'); await done(page);
await page.waitForFunction(() => /recomputed/.test(document.querySelector('#qs-account')?.textContent ?? ''), null, { timeout: LONG });
const wrote = await page.textContent('#qs-written-by');
check(/1 by the assistant, then you \(scripted\)/.test(wrote) && /of 3 gates/.test(wrote) && (await page.textContent('#qs-verified')).startsWith('✓ verifies'),
`the run's receipt binds whose hand each gate is in, recomputed here (${wrote}; ${await page.textContent('#qs-account')})`);
// A line of work from an older version, then brought back in.
await page.click(`#qs-vs-list li[data-id=${mine}] .qs-vs-item`);
await page.click('#qs-vs-open');
await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, BASE, { timeout: LONG });
await setProgram(BASE.replace('h q[0];\n', 'h q[0];\nx q[2];\n'));
await record();
const heads = await page.$$eval('#qs-vs-list li', (ls) => ls.filter((l) => /a head|in the editor/.test(l.textContent)).length);
check(heads === 2 && (await page.$$('.qs-vs-svg .qs-vs-edge')).length >= 4, `an edit from an older version is a line of work of its own (${heads} heads)`);
const other = await page.$eval('#qs-vs-list li:not(.here)', (l) => l.dataset.id);
const otherHead = await page.$$eval('#qs-vs-list li', (ls) => ls.find((l) => /a head/.test(l.textContent))?.dataset.id);
await page.click(`#qs-vs-list li[data-id=${otherHead}] .qs-vs-item`);
await page.click('#qs-vs-merge');
await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG });
const merging = await page.$$eval('.qs-review-list > li', (ls) => ls.map((l) => l.textContent));
// Keep this line's x q[2]; take the other's edited CX.
for (const [i, t] of merging.entries()) if (/− x q\[2\]/.test(t)) await page.uncheck(`#qs-review-${i}`);
await page.click('#qs-review-apply');
await page.waitForFunction(() => /merged by you/.test(document.querySelector('#qs-vs-list li.here')?.textContent ?? ''), null, { timeout: LONG });
const merged = await doc();
check(/x q\[2\];/.test(merged) && /cx q\[0\], q\[2\];/.test(merged) && /from v\d+ and v\d+/.test(await page.textContent('#qs-vs-detail')),
`bringing a line of work back in is a merge of both (${other}, ${otherHead})`);
check(/the assistant, then you \(scripted\): 1 gate/.test(await page.textContent('#qs-vs-share')), 'the merge keeps each line’s author');
const count = (await page.$$('#qs-vs-list li')).length;
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#qs-vs-list li.here', { timeout: LONG });
const back = (await page.$$('#qs-vs-list li')).length;
check(back === count && /merged by you/.test(await here()) && (await doc()) === merged, `the versions come back with the page (${back} of ${count}; ${await here()})`);
const files = [];
page.on('download', (d) => files.push(d));
await page.click('#qs-vs-download');
await page.waitForFunction(() => /program\.history\.json/.test(document.querySelector('.qs-status')?.textContent ?? ''), null, { timeout: LONG });
await page.waitForTimeout(500);
const hist = files.find((d) => d.suggestedFilename() === 'program.history.json');
const saved = hist && JSON.parse(readFileSync(await hist.path(), 'utf8'));
const prog = files.find((d) => d.suggestedFilename() === 'program.qasm');
check(saved?.versions?.length === count && saved.versions.every((v) => v.seal?.key?.length === 64 && v.digest.length === 64) && prog && readFileSync(await prog.path(), 'utf8') === merged,
`Download saves the program and its history, every version sealed, as wai-quantum history verify reads them (${files.map((d) => d.suggestedFilename()).join(', ')})`);
await page.uncheck('#qs-vs-hands');
}
// The classical-shortcut lens.
{
let RING = 'OPENQASM 3.0;\nqubit[16] q;\nbit[16] c;\nh q;\n';
for (let i = 0; i < 16; i++) RING += `cz q[${i}], q[${(i + 1) % 16}];\n`;
RING += 'rz(0.3) q;\nc = measure q;\n';
await setProgram(RING);
await page.click('#qs-shortcuts');
await page.waitForSelector('#qs-shortcuts-summary', { timeout: LONG });
const summary = await page.textContent('#qs-shortcuts-summary');
const cut = await page.textContent('#qs-routes tr[data-route=cutrank]');
check(/cut-rank sum/.test(summary) && /widest cut 2 bits/.test(cut) && await page.$('#qs-routes tr.best[data-route=cutrank]') && /does not apply/.test(await page.textContent('#qs-routes tr[data-route=stabilizer]')),
`the lens prices a Clifford ring with a closing layer by the cut-rank sum (${summary})`);
check(/not searched/.test(await page.textContent('#qs-routes tr[data-route=tensor_network]')), 'the quick check leaves the contraction search for a thorough one');
await page.click('#qs-shortcuts-thorough');
await page.waitForFunction(() => /2\^/.test(document.querySelector('#qs-routes tr[data-route=tensor_network]')?.textContent ?? ''), null, { timeout: LONG });
check(true, `a thorough check searches a contraction tree (${(await page.textContent('#qs-routes tr[data-route=tensor_network]')).trim()})`);
await page.click('.qs-editor .cm-content');
await page.keyboard.press('ControlOrMeta+End');
await page.keyboard.type('\nh q[0];');
await page.waitForSelector('#qs-shortcuts-panel.stale .qs-stale-note', { timeout: LONG });
check(true, 'a check is marked stale once the program changes');
await page.selectOption('#qs-example', 'bell');
await page.waitForFunction(() => /2 gates/.test(document.querySelector('#qs-measures')?.textContent ?? ''), null, { timeout: LONG });
await page.click('#qs-shortcuts');
await page.waitForFunction(() => /^Classically easy at any width/.test(document.querySelector('#qs-shortcuts-summary')?.textContent ?? '') && !document.querySelector('#qs-shortcuts-panel.stale'), null, { timeout: LONG });
check(true, 'a Clifford circuit is classically easy at any width');
}
// Predict first.
{
await page.selectOption('#qs-example', 'bell');
await page.check('#qs-predict-first');
const ran = (await page.$$('.qs-hitem')).length;
await page.click('#qs-run');
await page.waitForSelector('#qs-predict', { timeout: LONG });
check((await page.$$('.qs-hitem')).length === ran && !(await page.$('.qs-method')), 'with Predict first, the run waits for a prediction');
await page.fill('#qs-predict-bits', '000');
await page.fill('#qs-predict-share', '50');
await page.click('#qs-predict-run');
check(/2 classical bits; the outcome has 3/.test(await page.textContent('#qs-predict-note')), 'an outcome of the wrong width is refused, with the width');
await page.fill('#qs-predict-bits', '00');
await page.click('#qs-predict-run');
await done(page);
await page.waitForSelector('#qs-predicted', { timeout: LONG });
const pred = await page.textContent('#qs-predicted');
check(/you said00 in 50%/.test(pred) && /the run00 in \d/.test(pred) && /(came out on top|Another outcome)/.test(pred) && /first prediction/.test(pred), `the prediction is set against the counts (${pred})`);
await page.uncheck('#qs-predict-first');
}
// Generators: a program with integer inputs, read at the inputs bar's values. The
// Workbench checks a family at every size and keeps the cheapest of each cell.
const CHAIN = 'OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] {\n cx q[i], q[i + 1];\n}\n';
const TREE = 'OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint k in [0:n] {\n for uint i in [0:2**k - 1] {\n if (2**k < n && i + 2**k < n) {\n cx q[i], q[i + 2**k];\n }\n }\n}\n';
const lintSays = (re) => page.waitForFunction((re) => new RegExp(re).test(document.querySelector('#qs-lint')?.textContent ?? ''), re.source, { timeout: LONG });
const wbLog = () => page.$$eval('#qs-wb-log li', (ls) => ls.map((l) => l.textContent));
const wbIdle = () => page.waitForFunction(() => !document.querySelector('#qs-wb-start').disabled, null, { timeout: LONG });
{
await setProgram(CHAIN.replace('qubit[n] q;\n', 'qubit[n] q;\nbit[n] c;\n') + 'c = measure q;\n');
await page.waitForSelector('#qs-inputs:not([hidden]) #qs-input-n', { timeout: LONG });
await lintSays(/4 qubits · 4 gates at n = 4/);
check(true, 'a generator shows its input under the editor, and the lint reads it at n = 4');
await page.fill('#qs-input-n', '3');
await page.dispatchEvent('#qs-input-n', 'change');
await lintSays(/3 qubits · 3 gates at n = 3/);
await page.waitForFunction(() => /^The circuit: 3 qubits/.test(document.querySelector('#qs-diagram svg')?.getAttribute('aria-label') ?? ''), null, { timeout: LONG });
check(true, 'a new value re-reads it: linted and drawn at n = 3');
await page.click('#qs-run');
await done(page);
const got = await counts(page);
check(got.length === 2 && got.every(([k]) => k === '000' || k === '111'), `it runs at its value: a GHZ state on 3 qubits (${JSON.stringify(got)})`);
await page.fill('#qs-input-n', '4');
await page.dispatchEvent('#qs-input-n', 'change');
await setProgram(CHAIN);
await page.click('#qs-wb-start');
await page.waitForFunction(() => /^1 evaluated/.test(document.querySelector('#qs-wb-stats')?.textContent ?? ''), null, { timeout: LONG });
const st = await page.textContent('#qs-wb-stats');
check(/Best e0: cost 17\.3 against the reference’s 17\.3, by reference/.test(st) && (await page.$$('#qs-wb-map .qs-ring')).length === 1,
`Start checks the reference at n = 3, 5 and 8 and seeds the archive with it (${st})`);
await page.click('#qs-wb-search');
await page.waitForFunction(() => /The blind search: 100 edits judged/.test(document.querySelector('#qs-wb-log')?.textContent ?? ''), null, { timeout: LONG });
await wbIdle();
const after = await page.textContent('#qs-wb-stats');
check(/^101 evaluated/.test(after), `the blind search judges a hundred edits, each by the engine (${after})`);
await setProgram(TREE);
await page.click('#qs-wb-submit');
await page.waitForFunction(() => /^Your program: /.test(document.querySelector('#qs-wb-log li')?.textContent ?? ''), null, { timeout: LONG });
await wbIdle();
const said = (await wbLog())[0];
check(/^Your program: kept as e\d+, cheaper than every generator before it \(cost 12\.3\)/.test(said) && /by person/.test(await page.textContent('#qs-wb-stats')),
`the reader's log-depth tree is kept as the cheapest, and named as theirs (${said})`);
await page.click('#qs-wb-map .qs-wb-best');
await page.waitForSelector('#qs-wb-open', { timeout: LONG });
const card = await page.textContent('#qs-wb-entry');
check(/cost 12\.3/.test(card) && /checked by \w+, sealed/.test(card) && /person/.test(card) && !/null/.test(card), `an entry shows its cost, how it was checked, its seal and who wrote it (${card.slice(0, 140)})`);
await setProgram(CHAIN);
await page.click('#qs-wb-open');
await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG });
await page.click('#qs-review-apply');
await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, TREE, { timeout: LONG });
check(/proposed by you/.test(await page.textContent('#qs-vs-list li.here')), 'Open in editor offers the kept generator, recorded as its proposer’s, the reader’s');
await page.click('#qs-wb-ask');
check(/needs one/.test((await wbLog())[0]), 'Ask the model, with none connected, says it needs one');
const files = [];
page.on('download', (d) => files.push(d));
await page.click('#qs-wb-save');
await page.waitForFunction(() => /^Saved workbench-/.test(document.querySelector('#qs-wb-log li')?.textContent ?? ''), null, { timeout: LONG });
await page.waitForTimeout(500);
const read = async (d) => JSON.parse(readFileSync(await d.path(), 'utf8'));
const [specFile, archiveFile] = [files.find((d) => !/archive/.test(d.suggestedFilename())), files.find((d) => /\.archive\.json$/.test(d.suggestedFilename()))];
const savedSpec = specFile && await read(specFile), savedArchive = archiveFile && await read(archiveFile);
check(savedSpec?.reference === CHAIN && savedArchive?.spec?.length === 64 && savedArchive.entries.length >= 2 && savedArchive.entries.every((e) => e.sealed?.receipt)
&& archiveFile.suggestedFilename() === specFile.suggestedFilename().replace(/\.json$/, '.archive.json'),
`Download saves the specification and its archive side by side, every entry sealed, as the command line reads them (${files.map((d) => d.suggestedFilename()).join(', ')})`);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => /Taken up again: 102 evaluated/.test(document.querySelector('#qs-wb-log')?.textContent ?? ''), null, { timeout: LONG });
check(/by person/.test(await page.textContent('#qs-wb-stats')) && (await page.inputValue('#qs-wb-sizes')) === 'n = 3, 5, 8', 'the archive and its specification come back with the page');
await lintSays(/qubits/);
}
// The assistant, against a scripted model server: it calls a tool, proposes a program
// the engine cannot read, gets the engine's reason back, and proposes a corrected one,
// which the Studio checks and runs before offering it.
{
const asked = [];
const GOOD = 'OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n';
const model = createServer((req, res) => {
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET, POST, OPTIONS' };
if (req.method === 'OPTIONS') { res.writeHead(204, cors).end(); return; }
if (req.url.endsWith('/models')) { res.writeHead(200, { ...cors, 'content-type': 'application/json' }).end(JSON.stringify({ data: [{ id: 'scripted' }] })); return; }
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const r = JSON.parse(body);
asked.push(r);
const msgs = r.messages;
// Alternatives' calls carry no tools: candidates, one of them wrong on purpose.
if (!r.tools) {
const want = msgs[1].content;
const content = /^Write \d+ generators/.test(want)
? `Idea: double the qubits that hold the state each layer.\n\`\`\`qasm\n${TREE}\`\`\`\nIdea: forget the Hadamard.\n\`\`\`qasm\n${CHAIN.replace('h q[0];\n', '')}\`\`\`\nIdea: use CZ.\n\`\`\`qasm\n${CHAIN.replace('cx q[i], q[i + 1];', 'h q[i + 1];\n cz q[i], q[i + 1];\n h q[i + 1];')}\`\`\``
: /^Write \d+ programs that are the same circuit/.test(want)
? 'Idea: the two Hadamards cancel, and the X moves past the CNOT.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\ncx q[0], q[1];\nx q[1];\nc = measure q;\n```\nIdea: drop the X.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\ncx q[0], q[1];\nc = measure q;\n```'
: 'Idea: a GHZ state on three qubits.\n```qasm\nOPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\nc = measure q;\n```\n**Idea:** entangle with CZ instead.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[1];\ncz q[0], q[1];\nh q[1];\nc = measure q;\n```';
res.writeHead(200, { ...cors, 'content-type': 'application/json' }).end(JSON.stringify({ choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }] }));
return;
}
const told = msgs.some((m) => m.role === 'user' && /could not read the program you proposed/.test(m.content));
const toolDone = msgs.some((m) => m.role === 'tool');
let message;
if (!toolDone) message = { role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'lint_qasm', arguments: JSON.stringify({ qasm: GOOD }) } }] };
else if (!told) message = { role: 'assistant', content: 'It entangles two qubits. A variant:\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nfoo q[0];\n```' };
else message = { role: 'assistant', content: 'Corrected:\n```qasm\n' + GOOD + '```' };
res.writeHead(200, { ...cors, 'content-type': 'application/json' }).end(JSON.stringify({ choices: [{ message, finish_reason: message.tool_calls ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } }));
});
});
await new Promise((r) => model.listen(0, '127.0.0.1', r));
await page.click('#qs-ai-gear');
await page.fill('#qs-ai-endpoint', `http://127.0.0.1:${model.address().port}/v1`);
await page.click('#qs-ai-connect');
await page.waitForFunction(() => /connected · scripted/.test(document.querySelector('#qs-ai-state')?.textContent ?? ''), null, { timeout: LONG });
check(true, 'the assistant connects to a model server and reads its models');
await page.click('.qs-ai-quick button >> text=Explain this circuit');
await page.waitForSelector('.qs-ai-apply', { timeout: LONG });
const log = await page.$$eval('#qs-ai-log > li', (ls) => ls.map((l) => l.textContent));
check(log.some((t) => t.includes('used lint_qasm')), 'the model\'s tool call ran on the engine, and the log shows it');
check(log.some((t) => /could not read the proposed program \(line 3: unknown gate foo\)/.test(t)), `a proposal the engine cannot read goes back with the engine's reason (${log.find((t) => /could not read/.test(t)) ?? 'none'})`);
const proposal = await page.textContent('.qs-ai-proposal');
check(/checked: 2 qubits, 2 gates/.test(proposal) && /ran here on Stabilizer tableau/.test(proposal), `the corrected proposal is checked and run before it is offered (${proposal})`);
check(asked.length === 3 && asked.every((r) => r.tools?.some((t) => t.function.name === 'run_job') && r.messages[0].role === 'system' && /Never state a probability/.test(r.messages[0].content)), 'every call to the model carries the engine\'s tools and the rule that numbers come from them');
const toolMsg = asked[1].messages.find((m) => m.role === 'tool');
check(toolMsg && JSON.parse(toolMsg.content).qubits === 2, 'the tool\'s answer went back to the model');
await page.click('.qs-ai-apply');
await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG });
check(/A proposal from the assistant \(scripted\)/.test(await page.textContent('#qs-review')), 'Apply offers the checked program as the assistant’s proposal, named');
await page.click('#qs-review-apply');
await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, GOOD, { timeout: LONG });
check(true, 'taken, it is the program in the editor');
check((await page.inputValue('#qs-ai-stance')) === 'questions' && (await page.inputValue('#qs-ai-initiative')) === 'asked' && /ask one short question/.test(asked[0].messages[0].content),
'by default the assistant asks questions first and speaks only when asked');
const quick = await page.$$eval('.qs-ai-quick button', (bs) => bs.map((b) => b.textContent));
check(quick.includes('Ask me a question') && quick.includes('Hint'), `it can be asked for a question or a hint (${quick.join(', ')})`);
check(asked.every((r) => !r.tools || r.tools.some((t) => t.function.name === 'critique_qasm') && r.tools.some((t) => t.function.name === 'check_equivalent')), 'its tools include the critique and the equivalence check');
// Runs refused twice: no hint offered while it speaks only when asked; offered, not sent, when allowed.
const calls = asked.length;
await setProgram('OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n');
for (let i = 0; i < 2; i++) { await page.click('#qs-run'); await done(page); }
check(await page.isHidden('#qs-ai-stuck'), 'while it speaks only when asked, refused runs offer no hint');
await page.selectOption('#qs-ai-initiative', 'hint');
for (let i = 0; i < 2; i++) { await page.click('#qs-run'); await done(page); }
check(await page.isVisible('#qs-ai-stuck') && asked.length === calls, 'allowed to, it offers a hint after refused runs, and asks the model nothing');
await page.selectOption('#qs-ai-initiative', 'asked');
// Improve and Explore with the model: the engine reads, measures and compares each.
const DRAFT = 'OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[0];\nx q[1];\ncx q[0], q[1];\nc = measure q;\n';
await setProgram(DRAFT);
await page.click('#qs-improve');
await page.waitForFunction(() => document.querySelectorAll('.qs-cand').length && !/looking/.test(document.querySelector('#qs-alt-body')?.textContent ?? ''), null, { timeout: LONG });
const cards = await page.$$eval('.qs-cand', (cs) => cs.map((c) => c.textContent));
const refused = await page.textContent('#qs-alt-refused').catch(() => '');
check(cards.some((t) => /the model/.test(t) && /the two Hadamards cancel/.test(t) && /same circuit · equal tableaus/.test(t)) && /drop the X: not the same circuit/.test(refused),
`Improve with the model: its rewrite checked the same circuit, its wrong one left out with the reason (${refused})`);
await page.fill('#qs-explore-aim', 'another entangler');
await page.click('#qs-explore');
await page.waitForFunction(() => /a GHZ state/.test(document.querySelector('#qs-alt-body')?.textContent ?? ''), null, { timeout: LONG });
const ideas = await page.$$eval('.qs-cand', (cs) => cs.map((c) => c.textContent));
const muse = asked.filter((r) => !r.tools).map((r) => r.messages[1].content);
check(ideas.length === 2 && ideas.some((t) => /3 qubits/.test(t) && /a different circuit/.test(t)) && ideas.some((t) => /entangle with CZ instead/.test(t)) && muse.some((m) => /toward this aim: another entangler/.test(m)),
`Explore: the model's ideas, each read and measured by the engine (${ideas.map((t) => t.slice(0, 60)).join(' | ')})`);
check((await doc()) === DRAFT, 'neither changes the program until one is applied');
// The Workbench asks the model for generators, and the engine judges each at every size.
await setProgram(CHAIN);
await page.click('#qs-wb-start');
await page.waitForFunction(() => /^1 evaluated/.test(document.querySelector('#qs-wb-stats')?.textContent ?? ''), null, { timeout: LONG });
await page.click('#qs-wb-ask');
await page.waitForFunction(() => /^use CZ: /.test(document.querySelector('#qs-wb-log li')?.textContent ?? ''), null, { timeout: LONG });
await wbIdle();
const wlog = await wbLog();
check(wlog.some((t) => /^double the qubits that hold the state each layer: kept as e\d+, cheaper than every generator before it/.test(t))
&& wlog.some((t) => /^forget the Hadamard: not verified: at n = 3: different/.test(t))
&& wlog.some((t) => /^use CZ: not verified: at n = 3: it uses `cz`, which the specification does not allow/.test(t)),
`the model's generators, each judged at every size: one kept, a wrong one and a forbidden gate left out (${wlog.slice(0, 3).join(' | ')})`);
const brief = asked.filter((r) => !r.tools).map((r) => r.messages[1].content).find((m) => /^Write 3 generators/.test(m)) ?? '';
check(/prepare the same state from \|0…0⟩/.test(brief) && /n = 3, 5, 8/.test(brief) && /only these gates: h, cx/.test(brief) && brief.includes('cx q[i], q[i + 1];') && /The cheapest found: cost 17\.3, the reference\./.test(brief),
'the model is told the specification and shown the cheapest found, with the cost the engine measured');
check(/by model: scripted/.test(await page.textContent('#qs-wb-stats')), 'the best is the model’s, and named as its');
await setProgram(DRAFT);
// The candidates come back within the editor's redraw delay: wait for the draft's diagram.
await page.waitForFunction(() => /4 gates/.test(document.querySelector('#qs-lint')?.textContent ?? '') && document.querySelectorAll('#qs-diagram .qc-flag').length === 2, null, { timeout: LONG });
check(!/null/.test(await page.textContent('#qs-alt-body')) && !/null/.test(await page.textContent('#qs-measures')), 'no empty part is written out as “null”');
await page.screenshot({ path: join(SHOTS, 'studio-alternatives.png'), fullPage: true });
model.close();
}
check(seen.errors.length === 0, `no console error${seen.errors.length ? ': ' + seen.errors.join(' | ') : ''}`);
check(seen.failed.length === 0, `no failed request${seen.failed.length ? ': ' + seen.failed.join(' | ') : ''}`);
await ctx.close();
{
const { ctx, page } = await open({ width: 390, height: 844 });
await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG });
const over = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
check(over <= 0, `at 390 px nothing scrolls sideways (overflow ${over} px)`);
await ctx.close();
}
await browser.close();
server.close();
console.log(`\n${fails === 0 ? 'every check passed' : `${fails} check(s) failed`}`);
process.exit(fails === 0 ? 0 : 1);