aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/workbench/qa-browser.js
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-24 17:14:54 +0100
committereunhhu <[email protected]>2026-09-24 17:14:54 +0100
commitb1ce1927022d374198441afafeb27199fc8a052a (patch)
tree6021f175f4c67ed03b44240eaa2f90d846f12f6f /prototypes/workbench/qa-browser.js
parentd34f9426d5317a1948f12f229439946728bc342a (diff)
downloadpure-b1ce1927022d374198441afafeb27199fc8a052a.tar.gz
pure-b1ce1927022d374198441afafeb27199fc8a052a.zip
Establish native micro runtime checkpoint
Diffstat (limited to 'prototypes/workbench/qa-browser.js')
-rw-r--r--prototypes/workbench/qa-browser.js228
1 files changed, 228 insertions, 0 deletions
diff --git a/prototypes/workbench/qa-browser.js b/prototypes/workbench/qa-browser.js
new file mode 100644
index 0000000..3590fff
--- /dev/null
+++ b/prototypes/workbench/qa-browser.js
@@ -0,0 +1,228 @@
+// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-workbench-blank.png', 'output/playwright/pure-workbench-authored.png', 'output/playwright/pure-workbench-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-workbench-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}