- 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)
201 lines
14 KiB
JavaScript
201 lines
14 KiB
JavaScript
// 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);
|