{sx.pendingEdits}
} {revisionExhausted &&diff --git a/.github/ISSUE_TEMPLATE/first_use_feedback.md b/.github/ISSUE_TEMPLATE/first_use_feedback.md new file mode 100644 index 0000000..eae4368 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/first_use_feedback.md @@ -0,0 +1,23 @@ +--- +name: First-use feedback +about: Tell us where you got stuck or what made an example easier to understand +title: '' +labels: '' +assignees: '' +--- + +## What were you trying to do? + +Try a [first-use task](https://github.com/berkebasbay/loopset/blob/main/docs/FEEDBACK.md), or describe your own goal. You do not need to understand the code. + +## Where did you get stuck? + +Which step, words or control were confusing? What did you expect to happen? + +## How far did you get? + +Did you finish on your own, finish with help, or stop? What would have helped? + +## Environment (if known) + +Browser, device or screen size, and app version. A screenshot or a small invented example is optional. Do not share personal/customer data, credentials or browser files. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..010a28c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Problem and result + +What concrete problem does this solve? Describe what someone can do after this change. + +## Validation + +Which relevant checks or real user actions did you verify? For numerical changes, include an independent expectation or invariant. Explain any known limitation. + +## Reproduction + +For a fix, give the smallest steps or synthetic example that shows it. Keep customer data, credentials, personal information and raw local logs out of the PR and attachments. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaef78f..f0036e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,5 @@ name: Verify changes -# Check every PR revision, including drafts; keep manual dispatch available. on: pull_request: branches: [main] @@ -15,11 +14,10 @@ concurrency: cancel-in-progress: true jobs: - verify: + checks: runs-on: ubuntu-latest timeout-minutes: 15 steps: - # Official release refs resolved through api.github.com on 2026-10-20. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -52,6 +50,7 @@ jobs: - run: npm run check:assets - run: npm run check:templates - run: npm run check:demos + - run: npm run check:docs - run: npm run typecheck - run: npm test - run: npm run build @@ -59,3 +58,39 @@ jobs: - run: node scripts/simulate.mjs --example factory --seed 4207 - run: node scripts/simulate.mjs examples/service.json --seed 4207 - run: node examples/verify.mjs + browser: + needs: checks + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + suite: [first-use, recovery-and-entry, templates, original-worlds] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - run: npx playwright install --with-deps chromium + - name: Check browser journey + env: + QUEUELAB_BROWSER_SUITE: ${{ matrix.suite }} + run: npm run test:browser + verify: + if: ${{ always() }} + needs: [checks, browser] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require source checks and every browser journey + env: + QUALITY_RESULT: ${{ needs.checks.result }} + BROWSER_RESULT: ${{ needs.browser.result }} + run: | + test "$QUALITY_RESULT" = success + test "$BROWSER_RESULT" = success diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 04f9153..d298ad9 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -14,6 +14,8 @@ concurrency: jobs: build: + outputs: + public_url: ${{ steps.pages.outputs.base_url }}/ runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -47,6 +49,7 @@ jobs: - run: npm run check:assets - run: npm run check:templates - run: npm run check:demos + - run: npm run check:docs - run: npm run typecheck - run: npm test - run: npm run test:headless @@ -60,9 +63,34 @@ jobs: uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: dist - deploy: + browser: needs: build runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + suite: [first-use, recovery-and-entry, templates, original-worlds] + env: + QUEUELAB_PUBLIC_URL: ${{ needs.build.outputs.public_url }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - run: npx playwright install --with-deps chromium + - name: Check built browser journey + env: + QUEUELAB_BROWSER_SUITE: ${{ matrix.suite }} + run: npm run test:browser + deploy: + needs: [build, browser] + runs-on: ubuntu-latest timeout-minutes: 10 permissions: pages: write diff --git a/.gitignore b/.gitignore index 0c7d816..e5f767c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,5 @@ dist-engine/ # Private development notes and local automation state progress.md -# Machine-local agent instructions -/AGENTS.override.md +# Local override documents +*.override.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fde60e..e642f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 1.0.0-rc.17 + +- Add keyboard links that bypass navigation and reach the workspace or current lesson. +- Defer the initial 3D world until the first-use welcome is dismissed and the current project is known. +- Add a direct guided-example entry that opens the welcome without replacing saved work. +- Keep process selection, calculations and template saves available when WebGL or the graphics module cannot load. +- Encode static PNG result cards on a CPU-backed offscreen canvas when supported, with the original bounded HTML fallback. +- Preserve the first digit typed immediately after opening a numeric field. +- Keep the scene size stable while a numeric field is unfinished. +- Keep private previews and generated engine files out of development reloads; block local private folders and override documents from dev-server delivery. +- Run isolated browser journeys before accepting PRs and publishing the demo, alongside documentation link checks. +- Add a first-use feedback guide and a focused first-contribution path. + +## Preview updates: rc.10–rc.16 + +- Prepare an on-demand catalogue of 1,141 browser-ready models with source attribution and validation. +- Introduce the facility/process/results workspace, empty sites, placed-object controls, process cells and reusable personal templates. +- Expand to twelve curated starters, each with two alternatives and deterministic numerical references. +- Publish English short/full walkthroughs and a visual README with a task-based documentation hub. +- Add and simplify the first-use lesson into one real task at a time: select, change a team, calculate, compare and save. +- Preserve original projects, results, history and local data through practice, failures, cancellation and refresh. + ## 1.0.0-rc.9 - Publish a browser demo with deployment-subpath-aware models, world previews, fonts and sharing metadata. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f6882a..cebff8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,21 @@ npm run setup:hooks npm run dev ``` +The dev watcher ignores generated private previews, caches and the standalone engine output, so building or collecting local evidence does not reload a working app. The dev server also blocks local private folders and override documents; Git ignore rules alone do not control HTTP delivery. + No backend, account or API key is required. Use invented data in issues, tests, screenshots and examples. Keep customer files, personal information, credentials, browser profiles and raw local logs out of contributions. +## Your first pull request + +Start with one small, reproducible problem. You can improve a confusing instruction, add a synthetic regression example, or make a control easier to use with a keyboard. + +1. Fork the repository, create a branch and run the app with the commands above. +2. Make the smallest complete change. Product wording lives in `src/ui/messages`; guides live in `docs`; tests belong beside the relevant contract under `tests`. +3. Check your change: `npm run check:docs` for guide/link edits, `npm run typecheck` and a focused unit test for code, and `npm run test:browser` for affected main workflows. The full required checks are below. +4. Open a PR explaining the problem, result and actual validation. A screenshot or tiny synthetic model can help; customer files and local machine details do not belong in the PR. + +For a starter, follow the [template contribution path](docs/TEMPLATES.md) before editing generated files. For numerical behavior, use an independently reasoned expectation. If you prefer not to code, [first-use feedback](docs/FEEDBACK.md) is also useful. + ## Change a starter template The collection currently contains twelve starters, each with an original plan and two alternatives. Edit the source definitions and presentation, then regenerate the portable projects: @@ -96,6 +109,7 @@ Match verification to the change. For prose-only changes, check the referenced c ```sh npm run typecheck npm run check:assets +npm run check:docs npm test -- --maxWorkers=1 npm run build npm run test:headless diff --git a/README.md b/README.md index 5cdcff3..b647580 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Loopset is free and runs in your browser. Your projects stay on your device, with no account or installation required. +**New to simulation? [Try the guided example →](https://berkebasbay.github.io/loopset/?learn=1)** Select a help desk, add one person, and see how waiting and cost change. Your current project is kept. + ## See it in action From a working template to a real comparison: explore the world, change the shared team and run the next plan. @@ -105,6 +107,8 @@ Open the local address printed by Vite. No API keys or backend setup are require ## Help build Loopset +Try a [first-use task](docs/FEEDBACK.md), tell us where you got stuck, or make a [small first contribution](CONTRIBUTING.md#your-first-pull-request). + New templates, clearer guides, accessible interactions and focused bug fixes are welcome. [Open an issue](https://github.com/berkebasbay/loopset/issues/new/choose) or [send a pull request](CONTRIBUTING.md). For a larger change, discuss the problem and scope first. --- diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md new file mode 100644 index 0000000..99af441 --- /dev/null +++ b/docs/FEEDBACK.md @@ -0,0 +1,32 @@ +# Help improve first use + +You can contribute by trying Loopset and explaining where it becomes confusing. No coding or simulation experience is required. + +[Open the guided example →](https://berkebasbay.github.io/loopset/?learn=1) · [Send first-use feedback →](https://github.com/berkebasbay/loopset/issues/new?template=first_use_feedback.md) + +Use only made-up data. Your projects stay in this browser; downloading project JSON gives you a portable copy. + +## Try one task + +| Task | Goal | A useful observation | +| --- | --- | --- | +| First example | Complete the guided help-desk lesson and keep your example as a template. | Where did you need to reread, search or ask for help? Could you explain the result in your own words? | +| Your own change | Open a starter, change one assumption and compare the result with its original plan. | Was it clear what changed, which calculation you were looking at, and what the result meant? | +| Empty site | Start with Blank, place a model, choose Start a process, and save your setup. | Was the difference between arranging the space and changing how work happens clear? | + +Start without reading the detailed guides. If you need help, use the [Quickstart](QUICKSTART.md) and mention where you needed it. You can stop at any time; unfinished tasks are useful feedback too. + +## Tell us what happened + +1. State your goal and the actions you tried. +2. Name the point where you paused, guessed, needed help or stopped. +3. Say whether you finished on your own, with help, or did not finish. +4. Describe what you expected to see instead. Include your browser/device if known. + +A short account of one confusing moment is more useful than a score alone. Timing is optional. Screenshots or recordings are optional; review them for private information before attaching them to a public issue. + +Use [first-use feedback](https://github.com/berkebasbay/loopset/issues/new?template=first_use_feedback.md) for clarity and discoverability, or a [bug report](https://github.com/berkebasbay/loopset/issues/new?template=bug_report.md) for reproducible wrong behavior. Report security concerns through [Security](../SECURITY.md). + +## Reading feedback + +Distinguish observed behavior from a suggested solution. Include the application revision and the task when comparing results across versions. Browser emulation and automated checks cannot replace accounts from people trying the product for the first time. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index dffc30e..0af3457 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -7,6 +7,7 @@ npm ci npm run typecheck npm run check:assets npm run check:templates +npm run check:docs npm test -- --maxWorkers=1 npm run test:publication npm run build @@ -17,6 +18,20 @@ The unit suite covers numerical references, conservation/capacity invariants, ev ## Browser smoke checks +The required PR check and Pages workflow run `npm run test:browser` against the built app. Four isolated browser jobs run in parallel; the final required `verify` check needs the source checks and every browser job to succeed. Deployment also waits for all four jobs. Each browser job builds the same checked-out source with the same deployment base; no additional build-artifact storage is introduced. This starts its own loopback preview, checks first use, no-3D/module failures, safe guided entry, templates and the original worlds, then stops only its own processes. Linux CI uses software graphics and chooses the existing Low power view through the real UI in isolated Chromium contexts. The setup asserts that changing display detail leaves numerical identity unchanged. All interaction, result and persistence assertions remain active; these are correctness checks, not GPU performance measurements. Local detailed-renderer checks are separate. + +Install the browser matching the pinned Playwright dependency once with `npx playwright install chromium` (CI uses `npx playwright install --with-deps chromium`, as in the [Playwright CI guide](https://playwright.dev/docs/ci-intro)). Then run: + +```sh +npm run build +npm run test:browser +``` + +The runner fails if its preview port is occupied. Set `QUEUELAB_BROWSER_PORT` to a free port; it never stops an existing server. `QUEUELAB_BROWSER_DIST` selects an alternative build folder. `QUEUELAB_BROWSER_SUITE` can select `first-use`, `recovery-and-entry`, `templates` or `original-worlds` for a focused run; unknown names fail. Without it, every suite runs. `QUEUELAB_PUBLIC_URL` supplies the built deployment subpath, so the Pages build is checked under the same `/loopset/` base. Generated evidence remains ignored; no browser profiles or customer files are used. + +Documentation checks validate local file/image links and common Markdown heading anchors, excluding fenced code examples. They do not make external website availability a build requirement. + + Serve a production build on loopback and run: ```sh diff --git a/docs/README.md b/docs/README.md index eb17744..c0a3d10 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,4 +57,6 @@ The supplied examples are learning models. Real decisions need appropriate input ## Get help +New to the product? Try a [first-use task](FEEDBACK.md) and tell us where you got stuck. No code or private project is needed. + [Open an issue](https://github.com/berkebasbay/loopset/issues/new/choose) with a small synthetic example, the actions you took and the result you expected. Keep personal or customer data out of public examples. diff --git a/index.html b/index.html index 76d6f57..704fc78 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@
+ diff --git a/package-lock.json b/package-lock.json index ad55064..e7a31ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "queuelab", - "version": "1.0.0-rc.16", + "version": "1.0.0-rc.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "queuelab", - "version": "1.0.0-rc.16", + "version": "1.0.0-rc.17", "license": "MIT", "dependencies": { "lucide-react": "1.60.0", diff --git a/package.json b/package.json index 50c0efb..daa4f9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "queuelab", - "version": "1.0.0-rc.16", + "version": "1.0.0-rc.17", "private": true, "type": "module", "description": "A playable process and capacity simulation laboratory", @@ -15,6 +15,8 @@ "simulate": "node scripts/simulate.mjs", "examples:export": "npm run build:engine && node scripts/export-examples.mjs", "test:headless": "npm run build:engine && node examples/verify.mjs", + "check:docs": "node --test tests/docs.test.mjs && node scripts/check-docs.mjs", + "test:browser": "node --test tests/browser-runner.test.mjs tests/dev-boundaries.test.mjs && node scripts/browser-checks.mjs", "test:learning": "node scripts/learning-smoke.mjs", "test:ux": "node scripts/ux-smoke.mjs", "setup:hooks": "node scripts/setup-hooks.mjs", diff --git a/scripts/browser-checks.mjs b/scripts/browser-checks.mjs new file mode 100644 index 0000000..5d7e657 --- /dev/null +++ b/scripts/browser-checks.mjs @@ -0,0 +1,59 @@ +// Own only this preview and its isolated synthetic browser checks. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { stripVTControlCharacters } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +const checks = [ + ['first-use', 'scripts/learning-smoke.mjs', {}], + ['recovery-and-entry', 'scripts/readiness-smoke.mjs', {}], + ['templates', 'scripts/templates-smoke.mjs', {}], + ['original-worlds', 'scripts/ux-smoke.mjs', { QUEUELAB_UX_PHASE: 'core' }], +]; +const selectedChecks = process.env.QUEUELAB_BROWSER_SUITE ? checks.filter(([name]) => name === process.env.QUEUELAB_BROWSER_SUITE) : checks; +assert(selectedChecks.length > 0, 'Unknown browser suite.'); +const root = fileURLToPath(new URL('..', import.meta.url)); +const port = Number(process.env.QUEUELAB_BROWSER_PORT || 4173); +assert(Number.isInteger(port) && port >= 1024 && port <= 65535, 'Choose a loopback test port between 1024 and 65535.'); +const base = process.env.QUEUELAB_PUBLIC_URL ? new URL(process.env.QUEUELAB_PUBLIC_URL).pathname : '/'; +assert(base.startsWith('/') && base.endsWith('/'), 'Use a slash-terminated deployment path.'); +const origin = `http://127.0.0.1:${port}${base}`; +const out = path.resolve(process.env.QUEUELAB_EVIDENCE_DIR || '.private/evidence/browser-checks'); +await fs.mkdir(out, { recursive: true }); +const server = spawn(process.execPath, [path.join(root, 'node_modules/vite/bin/vite.js'), 'preview', '--host', '127.0.0.1', '--port', String(port), '--strictPort', '--base', base, '--outDir', process.env.QUEUELAB_BROWSER_DIST || 'dist'], { cwd: root, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); +let current, interrupted = false; +const stop = () => { interrupted = true; current?.kill('SIGTERM'); server.kill('SIGTERM'); }; +process.once('SIGINT', stop); process.once('SIGTERM', stop); +async function close(child) { + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return; + const ended = once(child, 'exit'); child.kill('SIGTERM'); + const timer = setTimeout(() => child.kill('SIGKILL'), 3000); + try { await ended; } finally { clearTimeout(timer); } +} +try { + await new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => reject(new Error(`Preview did not start within 30 seconds: ${stripVTControlCharacters(output)}`)), 30000); + const fail = error => { clearTimeout(timer); reject(error); }; + server.once('error', fail); + server.once('exit', code => fail(new Error(`Preview exited (${code}): ${output}`))); + server.stdout.on('data', data => { output = (output + data).slice(-4096); if (stripVTControlCharacters(output).includes('Local:')) { clearTimeout(timer); resolve(); } }); + server.stderr.on('data', data => { output = (output + data).slice(-4096); }); + }); + const response = await fetch(origin, { signal: AbortSignal.timeout(10000) }); + assert.equal(response.status, 200, 'The owned preview must serve the built application.'); + assert.match(await response.text(), /id=["']root["']/); + for (const [name, script, extra] of selectedChecks) { + assert(!interrupted, 'Browser checks interrupted.'); + console.log(`Browser check: ${name}`); + current = spawn(process.execPath, [script], { cwd: root, env: { ...process.env, ...extra, QUEUELAB_URL: origin, QUEUELAB_EVIDENCE_DIR: path.join(out, name) }, stdio: 'inherit' }); + const [code, signal] = await once(current, 'exit'); current = null; + assert.equal(code, 0, `${name} failed${signal ? ` (${signal})` : ''}.`); + } + console.log(`Browser checks passed: ${selectedChecks.map(([name]) => name).join(', ')}.`); +} finally { + await close(current); await close(server); + process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); +} diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000..00372f2 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,56 @@ +import { readFileSync, existsSync, statSync, realpathSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function content(markdown) { + let fence = ''; + return markdown.split('\n').filter(line => { + const mark = line.match(/^\s{0,3}(`{3,}|~{3,})/); + if (mark) { if (!fence) fence = mark[1]; else if (mark[1][0] === fence[0] && mark[1].length >= fence.length) fence = ''; return false; } + return !fence; + }).join('\n'); +} +export function headingAnchors(markdown) { + const used = new Set(); + for (const match of content(markdown).matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) { + const base = match[1].replace(/<[^>]*>/g, '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').trim().toLowerCase().replace(/[^\p{L}\p{N}\p{M}_ -]/gu, '').replace(/ /g, '-'); + let id = base, n = 0; while (used.has(id)) id = `${base}-${++n}`; + used.add(id); + } + for (const match of content(markdown).matchAll(/\b(?:id|name)=["']([^"']+)["']/g)) used.add(match[1]); + return used; +} +export function checkDocumentation(root, files) { + root = realpathSync(root); + const errors = [], anchors = new Map(); let count = 0; + for (const file of files) { + const text = content(readFileSync(path.join(root, file), 'utf8')).replace(/`[^`\n]+`/g, ''); + const links = [...text.matchAll(/!?\[[^\]\n]*\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+["'][^\n]*?["'])?\s*\)/g)].map(match => match[1] || match[2]); + links.push(...[...text.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)].map(match => match[1])); + for (const link of links) { + if (/^(?:https?:|mailto:|\/\/)/i.test(link)) continue; + count++; + try { + const [url, hash] = link.split('#'), pathname = decodeURIComponent(url.split('?')[0]); + const target = pathname ? path.resolve(pathname.startsWith('/') ? root : path.dirname(path.join(root, file)), pathname.startsWith('/') ? `.${pathname}` : pathname) : path.join(root, file); + const relative = path.relative(root, target); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !existsSync(target) || !statSync(target).isFile()) throw new Error('target file is missing or outside the repository'); + const actual = path.relative(root, realpathSync(target)); + if (actual === '..' || actual.startsWith(`..${path.sep}`) || path.isAbsolute(actual)) throw new Error('target symlink is outside the repository'); + if (hash && path.extname(target).toLowerCase() === '.md') { + if (!anchors.has(target)) anchors.set(target, headingAnchors(readFileSync(target, 'utf8'))); + if (!anchors.get(target).has(decodeURIComponent(hash))) throw new Error('heading anchor is missing'); + } + } catch (error) { errors.push(`${file}: ${link} — ${error.message}`); } + } + } + return { files: files.length, links: count, errors }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = process.cwd(); + const files = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', '*.md'], { encoding: 'utf8' }).split('\0').filter(Boolean))]; + const result = checkDocumentation(root, files); + if (result.errors.length) { console.error(result.errors.join('\n')); process.exitCode = 1; } + else console.log(`Documentation checks passed: ${result.files} files, ${result.links} local links and anchors.`); +} diff --git a/scripts/learning-smoke.mjs b/scripts/learning-smoke.mjs index 704156a..1725950 100644 --- a/scripts/learning-smoke.mjs +++ b/scripts/learning-smoke.mjs @@ -3,16 +3,17 @@ import { chromium } from 'playwright'; import fs from 'node:fs/promises'; import path from 'node:path'; import assert from 'node:assert/strict'; -import { dismissLearningWelcome, openPanel, runPlan } from './ui-actions.mjs'; +import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel, runPlan } from './ui-actions.mjs'; const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/'); assert(['127.0.0.1', 'localhost'].includes(origin.hostname), 'Use a separate loopback QA origin.'); const out = path.resolve(process.env.QUEUELAB_EVIDENCE_DIR || '.private/evidence/learning'); await fs.mkdir(out, { recursive: true }); await fs.mkdir('.cache/tmp', { recursive: true }); process.env.TMPDIR = path.resolve('.cache/tmp'); let installed = true; try { await fs.access(chromium.executablePath()); } catch { installed = false; } -const browser = await chromium.launch({ headless: true, ...(process.env.QUEUELAB_SYSTEM_CHROME || !installed ? { channel: 'chrome' } : { executablePath: chromium.executablePath() }) }); +const browser = await chromium.launch({ headless: true, args: browserArgs, ...(process.env.QUEUELAB_SYSTEM_CHROME || !installed ? { channel: 'chrome' } : { executablePath: chromium.executablePath() }) }); const scope = process.env.QUEUELAB_LEARNING_SCOPE || 'all'; assert(['all', 'flows', 'edges'].includes(scope)); const checks = [], errors = []; let page, phase = 'first visit'; +function recordCheck(message) { checks.push(message); console.log(`PASS: ${message}`); } const state = p => p.evaluate(() => JSON.parse(window.render_game_to_text())); const ready = p => p.waitForFunction(() => { const s = JSON.parse(window.render_game_to_text?.() || '{}'); return !s.running && s.result?.status === 'completed'; }); const coach = p => p.locator('.learning-coach'); @@ -21,7 +22,7 @@ const cap = p => p.locator('[data-learning-team] input:visible,[data-learning-ta const saved = p => p.locator('.workspace-footer').getByText(/^Saved in this browser/).first().waitFor({ state: 'attached' }); async function fresh(width = 1440, skip = false) { const context = await browser.newContext({ viewport: { width, height: width > 740 ? 1000 : 844 }, reducedMotion: 'reduce', acceptDownloads: true }); - const p = await context.newPage(); p.on('pageerror', e => errors.push(String(e))); await p.goto(origin.href); await ready(p); + const p = await context.newPage(); p.on('pageerror', e => errors.push(String(e))); await p.goto(origin.href); await ready(p); await prepareCIView(p); if (skip) await dismissLearningWelcome(p); return { context, page: p }; } async function start(p) { @@ -66,6 +67,7 @@ try { await page.getByRole('button', { name: 'Show the full workspace', exact: true }).click(); await page.waitForFunction(() => document.activeElement?.matches('[data-learning-resume]')); await page.locator('[data-learning-resume]').press('Enter'); await stage(page, 'select'); + if (width <= 740) { const bounds = await coach(page).getByRole('button', { name: 'Choose Specialists', exact: true }).boundingBox(); assert(bounds && bounds.y >= 0 && bounds.y + bounds.height <= page.viewportSize().height + 1, 'The first lesson action must fit the mobile viewport.'); } // The visible scene label is a real keyboard selection alternative to the lesson button. if (width === 1440) await page.locator('.world-scene [data-station-id="station-b"]').press('Enter'); else await page.getByRole('button', { name: 'Choose Specialists', exact: true }).click(); @@ -96,19 +98,19 @@ try { await coach(page).scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(out, `complete-${width}.png`) }); assert.equal(await coach(page).evaluate(el => getComputedStyle(el).animationName), 'none'); await page.getByRole('button', { name: 'Open the full workspace', exact: true }).click(); await saved(page); - await page.reload(); await ready(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0); + await page.reload(); await ready(page); await prepareCIView(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0); assert.deepEqual((await download(page, `reopened-${width}`)).model, changed.model); await openPanel(page, 'worlds'); await page.getByRole('button', { name: /^My templates/ }).click(); await page.locator('[data-template-id]').first().waitFor(); assert.equal(await page.locator('[data-template-id]').count(), 1); - await c.context.close(); checks.push(`${width}px: five real steps, pending-field guard, keyboard/focus, native persisted template, reload and reduced motion`); + await c.context.close(); recordCheck(`${width}px: five real steps, pending-field guard, keyboard/focus, native persisted template, reload and reduced motion`); } if (scope !== 'flows') { phase = 'resume and optional completion'; { const c = await fresh(); page = c.page; await start(page); await change(page); await saved(page); const before = await download(page, 'before-refresh'); - await page.reload(); await stage(page, 'run'); assert.deepEqual((await download(page, 'after-refresh')).model, before.model); + await page.reload(); await stage(page, 'run'); await prepareCIView(page); assert.deepEqual((await download(page, 'after-refresh')).model, before.model); assert.equal((await state(page)).result.stale, true); await compare(page); await keep(page); await page.getByRole('button', { name: 'Finish without saving a template', exact: true }).click(); await stage(page, 'complete'); assert.match(await page.locator('.learning-saved').innerText(), /No template was saved/); await c.context.close(); - checks.push('Refresh resumes the preserved changed model and requires a real rerun; optional finish makes no saved-template claim'); + recordCheck('Refresh resumes the preserved changed model and requires a real rerun; optional finish makes no saved-template claim'); } phase = 'original project and history'; { const c = await fresh(1440, true); page = c.page; @@ -133,7 +135,7 @@ try { await page.getByRole('button', { name: 'Back to my previous project', exact: true }).click(); await coach(page).waitFor({ state: 'detached' }); await ready(page); assert.equal((await download(page, 'newer-return')).name, 'Newer synthetic project'); assert.equal(await page.getByRole('button', { name: /^Undo / }).count(), 0); - await c.context.close(); checks.push('A failed preservation write blocks practice; original project, result and Undo survive practice; a newer externally saved original is reopened without stale history'); + await c.context.close(); recordCheck('A failed preservation write blocks practice; original project, result and Undo survive practice; a newer externally saved original is reopened without stale history'); } phase = 'save failure'; { const c = await fresh(); page = c.page; await start(page); await change(page); await compare(page); await keep(page); @@ -142,7 +144,7 @@ try { await page.locator('.template-save button[type=submit]').click(); await page.locator('.templates-panel [role=alert]').waitFor(); await stage(page, 'save'); assert.equal(await page.locator('.learning-saved').count(), 0); assert.equal(await templateCount(page), 0); await page.evaluate(() => window.restoreTemplateWrites()); await page.locator('.template-save button[type=submit]').click(); await stage(page, 'complete'); - await c.context.close(); checks.push('A failed IndexedDB template write cannot complete the lesson or claim a save; native retry succeeds'); + await c.context.close(); recordCheck('A failed IndexedDB template write cannot complete the lesson or claim a save; native retry succeeds'); } phase = 'cancelled and failed practice loading'; { const c = await fresh(1440, true); page = c.page; const before = await download(page, 'before-load-failure'); @@ -156,7 +158,7 @@ try { await page.locator('.studio-world-picker select').selectOption('blank'); await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).workspaceMode === 'layout'); const blank = await download(page, 'after-cancelled-load'); assert.deepEqual(blank.model.nodes, []); assert.notEqual(blank.id, before.id); assert.equal(await coach(page).count(), 0); await page.unroute('**/templates/service.json?*'); await c.context.close(); - checks.push('HTTP errors stay actionable; Escape cancels practice loading and a late response cannot replace the newer blank project'); + recordCheck('HTTP errors stay actionable; Escape cancels practice loading and a late response cannot replace the newer blank project'); } phase = 'independent exploration'; { const c = await fresh(); page = c.page; await start(page); await change(page); @@ -174,13 +176,14 @@ try { const different = await download(page, 'independent-idea'); await page.getByRole('button', { name: 'Leave guide', exact: true }).click(); assert.deepEqual((await download(page, 'after-leave')).model, different.model); - await saved(page); await page.reload(); await ready(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0); - await c.context.close(); checks.push('Native drafts block resume until applied/cancelled; resume closes advanced dialogs; Undo returns to the relevant task; unrelated model edits are preserved and leaving the guide never resets them'); + await saved(page); await page.reload(); await ready(page); await prepareCIView(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0); + await c.context.close(); recordCheck('Native drafts block resume until applied/cancelled; resume closes advanced dialogs; Undo returns to the relevant task; unrelated model edits are preserved and leaving the guide never resets them'); } } assert.deepEqual(errors, []); const receipt = { passed: true, at: new Date().toISOString(), origin: origin.href, browser: browser.version(), scope, checks, errors }; await fs.writeFile(path.join(out, 'receipt.json'), JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt)); } catch (error) { if (page && !page.isClosed()) { await page.screenshot({ path: path.join(out, 'failure.png'), fullPage: true }).catch(() => {}); await fs.writeFile(path.join(out, 'failure.txt'), `${phase}\n${error}\n${await page.locator('body').innerText()}`); } + if (page && !page.isClosed()) console.error('Focus state:', await page.evaluate(() => ({ stage: document.querySelector('.learning-coach')?.getAttribute('data-learning-stage'), active: document.activeElement?.outerHTML.slice(0, 500), running: JSON.parse(window.render_game_to_text?.() || '{}').running })).catch(() => null)); console.error(phase, error); process.exitCode = 1; } finally { await browser.close(); } diff --git a/scripts/readiness-smoke.mjs b/scripts/readiness-smoke.mjs new file mode 100644 index 0000000..ce59882 --- /dev/null +++ b/scripts/readiness-smoke.mjs @@ -0,0 +1,113 @@ +import { chromium } from 'playwright'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel } from './ui-actions.mjs'; +const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/'); +assert(['127.0.0.1', 'localhost'].includes(origin.hostname), 'Use an isolated loopback origin.'); +const out = path.resolve(process.env.QUEUELAB_EVIDENCE_DIR || '.private/evidence/readiness'); +await fs.mkdir(out, { recursive: true }); +let installed = true; try { await fs.access(chromium.executablePath()); } catch { installed = false; } +const browser = await chromium.launch({ headless: true, args: browserArgs, ...(process.env.QUEUELAB_SYSTEM_CHROME || !installed ? { channel: 'chrome' } : { executablePath: chromium.executablePath() }) }); +const checks = [], errors = []; let page, stage = 'startup'; +const state = p => p.evaluate(() => JSON.parse(window.render_game_to_text())); +const ready = p => p.waitForFunction(() => { const s = JSON.parse(window.render_game_to_text?.() || '{}'); return !s.running && s.result?.status === 'completed'; }); +const saved = p => p.locator('.workspace-footer').getByText(/^Saved in this browser/).first().waitFor({ state: 'attached' }); +async function download(p, name) { + const next = p.waitForEvent('download'); await p.locator('.studio-save').click(); const file = await next, target = path.join(out, `${name}.json`); await file.saveAs(target); return JSON.parse(await fs.readFile(target, 'utf8')); +} +try { + stage = 'graphics begin only after entry'; { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' }); + page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); + const graphics = url => /\/scene\/WorldScene\.tsx$|\/assets\/WorldScene-[^/]+\.js$/.test(new URL(url).pathname); + const requests = []; page.on('request', request => { if (graphics(request.url())) requests.push(request.url()); }); + await page.goto(new URL('?learn=1', origin).href); await ready(page); + await page.locator('[data-panel=learn]').waitFor(); await page.locator('.learning-intro img').evaluate(img => img.decode()); + assert.equal(requests.length, 0, 'The welcome must not fetch a temporary graphics module.'); + await Promise.all([page.waitForRequest(request => graphics(request.url())), page.getByRole('button', { name: 'Try it with me', exact: true }).click()]); + assert.equal((await state(page)).world, 'service'); assert.equal(requests.length, 1); + await context.close(); checks.push('The welcome loads no temporary graphics module; choosing the example starts its real 3D module on demand'); + } + stage = 'without WebGL'; { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce', acceptDownloads: true }); + await context.addInitScript(() => { const get = HTMLCanvasElement.prototype.getContext; window.restoreTestContext = () => { HTMLCanvasElement.prototype.getContext = get; }; HTMLCanvasElement.prototype.getContext = function (kind, ...args) { return kind.startsWith('webgl') || kind === 'experimental-webgl' ? null : get.call(this, kind, ...args); }; }); + page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); + await page.goto(new URL('?learn=1', origin).href); await ready(page); await prepareCIView(page); await page.getByRole('button', { name: 'Try it with me', exact: true }).click(); + await page.locator('[data-learning-stage=select]').waitFor(); await page.locator('.scene-text-fallback').waitFor(); + await page.screenshot({ path: path.join(out, 'no-webgl-lesson.png') }); + await page.getByRole('link', { name: 'Skip to lesson', exact: true }).focus(); await page.keyboard.press('Enter'); + assert(await page.locator('[data-learning-heading]').evaluate(e => e === document.activeElement)); + await page.locator('.scene-text-fallback [data-station-id=station-b]').click(); + await page.getByRole('spinbutton', { name: 'People on this team', exact: true }).fill('3'); + await page.getByRole('button', { name: 'Apply change', exact: true }).click(); + await page.getByRole('button', { name: 'Try this change', exact: true }).click(); await page.locator('[data-learning-stage=review]').waitFor(); + assert.equal((await state(page)).result.summary.completed, 86); + await page.getByRole('button', { name: 'Got it. Next', exact: true }).click(); + await page.getByRole('textbox', { name: 'Name your example', exact: true }).fill('Without 3D'); + await page.getByRole('button', { name: 'Save my example', exact: true }).click(); await page.locator('[data-learning-stage=complete]').waitFor(); + await page.getByRole('button', { name: 'Open the full workspace', exact: true }).click(); await saved(page); + const retained = await download(page, 'without-webgl'); const before = (await state(page)).result; + await page.evaluate(() => window.restoreTestContext()); await page.getByRole('button', { name: 'Retry 3D world', exact: true }).click(); + await page.locator('.world-scene[data-status=ready]').waitFor(); assert.equal(await page.locator('.scene-text-fallback').count(), 0); + assert.deepEqual((await state(page)).result, before); assert.deepEqual((await download(page, 'after-webgl-retry')).model, retained.model); + await page.screenshot({ path: path.join(out, 'recovered-3d.png') }); await context.close(); + checks.push('No WebGL: real DOM selection, edit, calculation, template ACK and download work; Retry restores 3D without changing the model or result'); + } + stage = 'graphics module fails to load'; { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' }); + page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); + await page.route(url => /\/scene\/WorldScene\.tsx$|\/assets\/WorldScene-[^/]+\.js$/.test(url.pathname), route => route.abort('failed')); + await page.goto(new URL('?learn=1', origin).href); await ready(page); await prepareCIView(page); + await page.getByRole('button', { name: 'Try it with me', exact: true }).click(); + await page.locator('[data-learning-stage=select]').waitFor(); + await page.locator('.scene-unavailable .scene-text-fallback').waitFor(); + await page.screenshot({ path: path.join(out, 'no-graphics-lesson.png') }); + await page.locator('.scene-unavailable [data-station-id=station-b]').press('Enter'); + await page.getByRole('spinbutton', { name: 'People on this team', exact: true }).fill('3'); + await page.getByRole('button', { name: 'Apply change', exact: true }).click(); + await page.getByRole('button', { name: 'Try this change', exact: true }).click(); + await page.locator('[data-learning-stage=review]').waitFor(); assert.equal((await state(page)).result.summary.completed, 86); + await page.getByRole('button', { name: 'Got it. Next', exact: true }).click(); + await page.getByRole('textbox', { name: 'Name your example', exact: true }).fill('Without graphics module'); + await page.getByRole('button', { name: 'Save my example', exact: true }).click(); + await page.locator('[data-learning-stage=complete]').waitFor(); + await page.screenshot({ path: path.join(out, 'without-graphics-module.png') }); await context.close(); + checks.push('A failed lazy graphics module leaves the lesson, actual calculations and template save usable'); + } + stage = 'guided entry preserves saved work'; { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); + page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await prepareCIView(page); await saved(page); + await page.getByRole('link', { name: 'Skip to workspace', exact: true }).focus(); await page.keyboard.press('Enter'); + assert(await page.locator('#workspace').evaluate(e => e === document.activeElement)); + const original = await download(page, 'before-guided-entry'); + await page.goto(new URL('?learn=1&keep=example', origin).href); await ready(page); await prepareCIView(page); await page.locator('[data-panel=learn]').waitFor(); + assert.equal(new URL(page.url()).searchParams.get('learn'), null); assert.equal(new URL(page.url()).searchParams.get('keep'), 'example'); + await page.getByRole('button', { name: 'Explore on my own', exact: true }).click(); + const after = await download(page, 'after-guided-entry'); + for (const key of Object.keys(original).filter(key => key !== 'savedAt')) assert.deepEqual(after[key], original[key]); + await page.reload(); await ready(page); await prepareCIView(page); assert.equal(await page.locator('[data-panel=learn]').count(), 0); + await context.close(); checks.push('Explicit ?learn=1 opens the welcome once, preserves the saved project and other query parameters, and does not reopen on reload'); + } + for (const width of [1440, 390]) { + stage = `pending edit ${width}`; + const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); + page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await prepareCIView(page); + await page.locator('.facility-canvas-tools').getByRole('button', { name: 'Steps', exact: true }).click(); + await page.getByRole('button', { name: 'Inspect Packing', exact: true }).click(); + const field = page.getByRole('spinbutton', { name: 'Mean processing time', exact: true }); + const size = () => page.locator('.world-scene').evaluate(e => ({ width: e.getBoundingClientRect().width, height: e.getBoundingClientRect().height })); + const before = await size(), numerical = (await state(page)).result; + await field.fill(''); await page.locator('.ease-pending-banner').waitFor(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.deepEqual(await size(), before); assert.deepEqual((await state(page)).result, numerical); + await page.screenshot({ path: path.join(out, `pending-${width}.png`) }); await field.press('Escape'); + await page.locator('.ease-pending-banner').waitFor({ state: 'detached' }); await context.close(); + checks.push(`${width}px: an unfinished number keeps scene dimensions and numerical results unchanged; Escape repairs the draft`); + } + assert.deepEqual(errors, []); const receipt = { passed: true, at: new Date().toISOString(), browser: browser.version(), checks, errors }; + await fs.writeFile(path.join(out, 'receipt.json'), JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt)); +} catch (error) { + if (page && !page.isClosed()) { await page.screenshot({ path: path.join(out, 'failure.png'), fullPage: true }).catch(() => {}); await fs.writeFile(path.join(out, 'failure.txt'), `${stage}\n${error}\n${await page.locator('body').innerText()}`); } + console.error(stage, error); process.exitCode = 1; +} finally { await browser.close(); } diff --git a/scripts/templates-smoke.mjs b/scripts/templates-smoke.mjs index b6b4159..83f8e2d 100644 --- a/scripts/templates-smoke.mjs +++ b/scripts/templates-smoke.mjs @@ -3,14 +3,14 @@ import { chromium } from 'playwright'; import fs from 'node:fs/promises'; import path from 'node:path'; import assert from 'node:assert/strict'; -import { dismissLearningWelcome, openPanel, runPlan, headerProjectInput, canvasDropPoint } from './ui-actions.mjs'; +import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel, runPlan, headerProjectInput, canvasDropPoint } from './ui-actions.mjs'; const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/'); assert(['127.0.0.1', 'localhost'].includes(origin.hostname), 'Use a separate loopback QA origin.'); const out = path.resolve(process.env.QUEUELAB_EVIDENCE_DIR || '.private/evidence/templates/smoke'); await fs.mkdir(out, { recursive: true }); await fs.mkdir('.cache/tmp', { recursive: true }); process.env.TMPDIR = path.resolve('.cache/tmp'); let installed = true; try { await fs.access(chromium.executablePath()); } catch { installed = false; } -const browser = await chromium.launch({ headless: true, ...(installed ? { executablePath: chromium.executablePath() } : { channel: 'chrome' }) }); +const browser = await chromium.launch({ headless: true, args: browserArgs, ...(installed ? { executablePath: chromium.executablePath() } : { channel: 'chrome' }) }); const checks = [], errors = []; let page, stage = 'startup'; const state = p => p.evaluate(() => JSON.parse(window.render_game_to_text())); const ready = p => p.waitForFunction(() => { const s = JSON.parse(window.render_game_to_text?.() || '{}'); return !document.querySelector('.studio-world-picker select')?.disabled && !s.running && (s.workspaceMode === 'layout' || s.result?.status === 'completed' && !s.result.stale); }); @@ -19,7 +19,7 @@ const close = p => p.getByRole('button', { name: 'Close dialog', exact: true }). async function fresh(width = 1440) { const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); const p = await context.newPage(); p.on('pageerror', e => errors.push(String(e))); - await p.goto(origin.href); await dismissLearningWelcome(p); await ready(p); return { context, page: p }; + await p.goto(origin.href); await dismissLearningWelcome(p); await ready(p); await prepareCIView(p); return { context, page: p }; } async function download(p, name) { const next = p.waitForEvent('download'); await p.locator('.studio-save').click(); @@ -68,7 +68,7 @@ try { await page.screenshot({ path: path.join(out, 'saved-template.png') }); const saved = await records(page); assert.equal(saved.templates.length, 1); assert.equal(saved.summaries.length, 1); await close(page); const unchanged = await download(page, 'after-template-save.json'); assert.equal(unchanged.id, placed.id); assert.equal(unchanged.revision, placed.revision); - await page.locator('.workspace-footer').getByText(/^Saved in this browser/).first().waitFor(); await page.reload(); await ready(page); + await page.locator('.workspace-footer').getByText(/^Saved in this browser/).first().waitFor(); await page.reload(); await ready(page); await prepareCIView(page); const restored = await download(page, 'layout-reloaded.json'); assert.equal(restored.id, placed.id); assert.deepEqual(restored.facility, placed.facility); assert.deepEqual(restored.model.nodes, []); await myTemplates(page); await page.locator('.templates-panel').getByRole('button', { name: 'Save as template', exact: true }).click(); await page.locator('.template-cancel-save').click(); assert.equal(await page.evaluate(() => document.activeElement?.textContent), 'Save as template'); await row(page, 'Synthetic workshop').getByRole('button', { name: 'Use template', exact: true }).click(); await ready(page); const first = await download(page, 'template-instance.json'); assert.notEqual(first.id, placed.id); assert.deepEqual(first.facility, placed.facility); assert.deepEqual(first.model, placed.model); @@ -122,7 +122,7 @@ try { tx.oncomplete = () => { db.close(); resolve(); }; tx.onabort = () => { db.close(); reject(String(tx.error)); }; }; }), saved.templates[0]); - const other = await desktop.context.newPage(); other.on('pageerror', e => errors.push(String(e))); await other.goto(origin.href); await dismissLearningWelcome(other); await ready(other); + const other = await desktop.context.newPage(); other.on('pageerror', e => errors.push(String(e))); await other.goto(origin.href); await dismissLearningWelcome(other); await ready(other); await prepareCIView(other); await myTemplates(page); await page.locator('.templates-panel').getByRole('button', { name: 'Save as template', exact: true }).click(); await myTemplates(other); await other.locator('.templates-panel').getByRole('button', { name: 'Save as template', exact: true }).click(); for (const [p, name] of [[page, 'Concurrent A'], [other, 'Concurrent B']]) { await p.bringToFront(); await p.getByRole('textbox', { name: 'Template name', exact: true }).fill(name); } // Activate the two real form handlers together; background-tab frame throttling must not serialize this transaction race. diff --git a/scripts/ui-actions.mjs b/scripts/ui-actions.mjs index 7cdf9b3..1069e6a 100644 --- a/scripts/ui-actions.mjs +++ b/scripts/ui-actions.mjs @@ -1,5 +1,26 @@ import assert from 'node:assert/strict'; +// Linux CI has no GPU; use deterministic software graphics in isolated test browsers. +export const browserArgs = process.env.CI && process.platform === 'linux' ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] : []; + +/** Keep software-rendered CI about interaction correctness, with real UI preferences. */ +export async function prepareCIView(page) { + if (!process.env.CI) return; + const welcome = await page.locator('[data-panel="learn"]').count() > 0; + if (welcome) await page.getByRole('button', { name: 'Explore on my own', exact: true }).click(); + const expanded = await page.locator('.learning-focused').count() > 0; + if (expanded) await page.getByRole('button', { name: 'Show the full workspace', exact: true }).click(); + const snapshot = () => page.evaluate(() => { const s = JSON.parse(window.render_game_to_text()); return { world: s.world, revision: s.revision, hash: s.result?.hash }; }); + const before = await snapshot(); + await page.locator('[data-panel-opener="view"]:visible').click(); + await page.getByRole('combobox', { name: 'World detail', exact: true }).selectOption('low'); + await page.getByRole('button', { name: 'Close dialog', exact: true }).click(); + assert.deepEqual(await snapshot(), before, 'Display detail must not change numerical identity.'); + if (welcome) await page.getByRole('button', { name: 'Learn by doing', exact: true }).click(); + else if (expanded) await page.locator('[data-learning-resume]').click(); + console.log(`CI view: Low power at ${page.viewportSize().width}px; numerical identity unchanged.`); +} + // Shared current-studio navigation for isolated browser regressions. These are // explicit product paths: missing controls fail instead of trying older UIs. const toolPanels = new Set(['model', 'experiments', 'budget', 'history', 'worlds', 'help']); diff --git a/scripts/ux-smoke.mjs b/scripts/ux-smoke.mjs index 7824bac..e07887c 100644 --- a/scripts/ux-smoke.mjs +++ b/scripts/ux-smoke.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import { createHash } from 'node:crypto'; -import { dismissLearningWelcome, openPanel, primaryRunButton, headerProjectInput } from './ui-actions.mjs'; +import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel, primaryRunButton, headerProjectInput } from './ui-actions.mjs'; const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/'); assert(['localhost', '127.0.0.1'].includes(origin.hostname), 'Use a loopback QA server.'); const kind = process.env.QUEUELAB_BROWSER || 'chromium', phase = process.env.QUEUELAB_UX_PHASE || 'core'; @@ -16,7 +16,7 @@ const worlds = (process.env.QUEUELAB_UX_WORLDS || 'factory,festival,service').sp assert(worlds.length > 0 && worlds.every(world => ['factory', 'festival', 'service'].includes(world))); const out = path.resolve(process.env.QUEUELAB_EVIDENCE_DIR || `.private/evidence/ux-smoke-${kind}-${phase}`); await fs.mkdir(out, { recursive: true }); await fs.mkdir('.cache/tmp', { recursive: true }); process.env.TMPDIR = path.resolve('.cache/tmp'); -const browser = await { chromium, firefox, webkit }[kind].launch({ headless: true, ...(kind === 'chromium' && process.env.QUEUELAB_SYSTEM_CHROME ? { channel: 'chrome' } : {}) }); +const browser = await { chromium, firefox, webkit }[kind].launch({ headless: true, ...(kind === 'chromium' ? { args: browserArgs } : {}), ...(kind === 'chromium' && process.env.QUEUELAB_SYSTEM_CHROME ? { channel: 'chrome' } : {}) }); const receipt = { origin: origin.href, browser: browser.version(), engine: kind, phase, viewport: { width: viewportWidth, height: viewportHeight }, worlds, checks: [], files: [], errors: [] }; const state = page => page.evaluate(() => JSON.parse(window.render_game_to_text())); const ready = page => page.waitForFunction(() => { const s = JSON.parse(window.render_game_to_text?.() || '{}'); return !document.querySelector('.studio-world-picker select')?.disabled && !s.running && s.result?.status === 'completed' && !s.result.stale; }); @@ -24,8 +24,15 @@ const close = page => page.getByRole('button', { name: 'Close dialog', exact: tr const check = (ids, name, evidence) => receipt.checks.push({ ids, name, evidence }); async function screenshot(page, name) { await page.screenshot({ path: path.join(out, name) }); return name; } async function saveDownload(page, button, name) { - const pending = page.waitForEvent('download'); await button.click(); const download = await pending; const file = path.join(out, name); await download.saveAs(file); - const bytes = await fs.readFile(file); receipt.files.push({ name, suggested: download.suggestedFilename(), bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }); return bytes; + try { + await button.click({ trial: true }); + const [download] = await Promise.all([page.waitForEvent('download'), button.click()]); + const file = path.join(out, name); await download.saveAs(file); + const bytes = await fs.readFile(file); receipt.files.push({ name, suggested: download.suggestedFilename(), bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }); return bytes; + } catch (error) { + console.error('Export state:', { file: name, alerts: await page.locator('[role=alert]').allTextContents().catch(() => []), status: await page.locator('.ql-result-exports,[data-panel=data] .ql-status').allTextContents().catch(() => []) }); + throw error; + } } const projectJSON = async (page, name) => JSON.parse((await saveDownload(page, page.locator('.studio-save'), name)).toString()); async function inspect(page, label, intent = 'single') { @@ -36,7 +43,7 @@ async function inspect(page, label, intent = 'single') { async function field(page, name, value, scope = page) { const input = scope.getByRole('spinbutton', { name, exact: true }); await input.fill(String(value)); await input.press('Enter'); return input; } async function fresh(world = 'factory', width = viewportWidth, height = viewportHeight) { const context = await browser.newContext({ viewport: { width, height }, reducedMotion: 'reduce', acceptDownloads: true }); const page = await context.newPage(); - page.on('pageerror', error => receipt.errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await ready(page); + page.on('pageerror', error => receipt.errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await ready(page); await prepareCIView(page); if (world !== 'factory') { await page.locator('.studio-world-picker select').selectOption(world); await ready(page); } const pause = page.getByRole('button', { name: 'Pause replay', exact: true }); if (await pause.count()) await pause.click(); return { context, page }; } diff --git a/src/App.tsx b/src/App.tsx index 145f6b8..c3d147a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,8 @@ import { appMessages as m } from './ui/messages/App'; import { learningMessages as lm } from './ui/messages/learning'; import { LearningIntro, LearningCoach } from './features/LearningGuide'; import { createLearningSession, learningReturnMatches, learningModelMatches, learningRunMatches, learningStage, originalLearningProject, readLearningSession, rememberLearningSession, hasSeenLearningWelcome, rememberLearningWelcome, LEARNING_STEP_ID, LEARNING_POOL_ID, type LearningSession, type LearningStage } from './features/learning'; +import { SceneBoundary, SceneFallback } from './scene/SceneFallback'; +import { worldSceneMessages } from './ui/messages/WorldScene'; import { assetURL } from './assetUrl'; import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ArrowDownToLine, ArrowRight, Box, Check, ChevronRight, CircleHelp, Copy, FileUp, FlaskConical, Focus, GitCompareArrows, Globe2, History, Layers3, Link2, List, LoaderCircle, MousePointer2, Move, Pause, Play, Plus, Redo2, Search, ShieldCheck, SkipBack, Undo2, X } from 'lucide-react'; @@ -142,6 +144,9 @@ export default function App() { const [flowState, setFlowState] = useState({ key: '', open: false, cursor: 0 }); const [toast, setToast] = useState{sx.pendingEdits}
} {revisionExhausted &&{m.buildingYourLittleWorld}
}>{worldSceneMessages.previewUnavailable}
}>{m.buildingYourLittleWorld}
}>{m.buildingYourLittleWorld}
{tm.layoutHint}