From b1ce1927022d374198441afafeb27199fc8a052a Mon Sep 17 00:00:00 2001 From: eunhhu Date: Thu, 24 Sep 2026 17:14:54 +0100 Subject: Establish native micro runtime checkpoint --- prototypes/workbench/qa-browser.js | 228 +++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 prototypes/workbench/qa-browser.js (limited to 'prototypes/workbench/qa-browser.js') 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); + } +} -- cgit v1.2.3