open-standards/wai/quantum-studio/test/studio.test.mjs
Transaction Science b8773cf47f Quantum Studio: the circuit as a diagram, from the engine's layout
- engine::draw::layout: the drawing as data. It gives the qubits, the columns (each item's cells as [qubit, label], and whether a line joins them) and the legend of conditions, packed as the text drawing packs them. The packing moves into one function, which both forms use.
- wai-web's engine binding gains engine_layout, and the desktop app gains `layout`.
- The Studio draws the circuit as SVG from it:
  - wires, gate boxes, a control's dot joined to its target's ring, swaps;
  - measurements as meters with their bit, resets;
  - a gate under a condition with a dashed outline and the condition's number.
  Colours come from the page's tokens, each item has a title naming its gates and qubits, and the SVG has a text alternative. "Text" shows the text drawing to copy. Past 400 columns the diagram stops and says so.
- Tests: the layout of a circuit with a shared column, a joined CX and a conditioned gate; the binding's layout. The Studio test adds the diagram's glyphs and text alternative, and the Text toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit c2e89a9fd9)
2026-10-20 17:34:17 -04:00

201 lines
14 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;
// - 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;
// - 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 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;
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');
check((await page.evaluate(() => window.quantumStudio.view.state.doc.toString())) === GOOD, 'Apply puts the checked program in the editor');
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);