From 14bc69085ff8bb5cb9a6d7183023257db53dad7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berke=20Ba=C5=9Fbay?= <132104179+berkebasbay@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:18:28 +0300 Subject: [PATCH 1/8] Protect first use and verify release journeys --- .github/ISSUE_TEMPLATE/first_use_feedback.md | 23 +++++ .github/PULL_REQUEST_TEMPLATE.md | 11 +++ .github/workflows/ci.yml | 5 + .github/workflows/pages.yml | 7 ++ .gitignore | 4 +- CHANGELOG.md | 20 ++++ CONTRIBUTING.md | 14 +++ README.md | 4 + docs/FEEDBACK.md | 32 +++++++ docs/QUALITY.md | 15 +++ docs/README.md | 2 + index.html | 1 + package-lock.json | 4 +- package.json | 4 +- scripts/browser-checks.mjs | 56 +++++++++++ scripts/check-docs.mjs | 56 +++++++++++ scripts/learning-smoke.mjs | 4 +- scripts/readiness-smoke.mjs | 99 ++++++++++++++++++++ scripts/templates-smoke.mjs | 4 +- scripts/ui-actions.mjs | 3 + scripts/ux-smoke.mjs | 4 +- src/App.tsx | 13 ++- src/features/EditFields.tsx | 6 +- src/features/LearningGuide.tsx | 2 +- src/features/facilityWorkspace.css | 2 +- src/scene/SceneFallback.tsx | 19 ++++ src/scene/WorldScene.tsx | 10 +- src/scene/sceneFallback.css | 4 + src/scene/workspaceScene.css | 2 + src/styles.css | 1 - src/ui/interface.css | 2 + src/ui/messages/App.ts | 5 +- src/ui/messages/WorldScene.ts | 7 +- src/ui/messages/learning.ts | 1 + tests/browser-runner.test.mjs | 19 ++++ tests/dev-boundaries.test.mjs | 25 +++++ tests/docs.test.mjs | 29 ++++++ vite.config.ts | 5 + 38 files changed, 499 insertions(+), 25 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/first_use_feedback.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 docs/FEEDBACK.md create mode 100644 scripts/browser-checks.mjs create mode 100644 scripts/check-docs.mjs create mode 100644 scripts/readiness-smoke.mjs create mode 100644 src/scene/SceneFallback.tsx create mode 100644 src/scene/sceneFallback.css create mode 100644 tests/browser-runner.test.mjs create mode 100644 tests/dev-boundaries.test.mjs create mode 100644 tests/docs.test.mjs 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..c9cacea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,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 +60,7 @@ 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 + - name: Install test browser + run: npx playwright install --with-deps chromium + - name: Check browser journeys + run: npm run test:browser diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 04f9153..a78a138 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -47,6 +47,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 @@ -56,6 +57,12 @@ jobs: run: | npm run build node --input-type=module -e "import fs from 'node:fs'; const pkg=JSON.parse(fs.readFileSync('package.json')); fs.writeFileSync('dist/build-info.json', JSON.stringify({source:process.env.GITHUB_SHA,version:pkg.version})+'\n');" + - name: Install test browser + run: npx playwright install --with-deps chromium + - name: Check built demo in the browser + env: + QUEUELAB_PUBLIC_URL: ${{ steps.pages.outputs.base_url }}/ + run: npm run test:browser - name: Upload static application uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: 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..a31b4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.0.0-rc.17 + +- Add keyboard links that bypass navigation and reach the workspace or current lesson. +- 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. +- 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..e8287e0 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 build run `npm run test:browser` against the built app. 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 in isolated Chromium contexts; these are correctness checks, not GPU performance measurements. + +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_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..b6d1a86 --- /dev/null +++ b/scripts/browser-checks.mjs @@ -0,0 +1,56 @@ +// 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 { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +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.')), 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 (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["']/); + 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' }], + ]; + for (const [name, script, extra] of checks) { + 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: first use, recovery, templates and original worlds.'); +} 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..998c298 100644 --- a/scripts/learning-smoke.mjs +++ b/scripts/learning-smoke.mjs @@ -3,13 +3,13 @@ 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 { 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'; diff --git a/scripts/readiness-smoke.mjs b/scripts/readiness-smoke.mjs new file mode 100644 index 0000000..a54fff8 --- /dev/null +++ b/scripts/readiness-smoke.mjs @@ -0,0 +1,99 @@ +import { chromium } from 'playwright'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { 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 = 'without WebGL'; { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, 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 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.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]').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 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 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.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 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 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); 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 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..f051894 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 { 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); }); diff --git a/scripts/ui-actions.mjs b/scripts/ui-actions.mjs index 7cdf9b3..cf78d57 100644 --- a/scripts/ui-actions.mjs +++ b/scripts/ui-actions.mjs @@ -1,5 +1,8 @@ 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'] : []; + // 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..6092263 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 { 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; }); diff --git a/src/App.tsx b/src/App.tsx index 145f6b8..b557269 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'; @@ -335,7 +337,11 @@ export default function App() { } else { rememberLearningSession(null); if (!isLayoutOnly(initial) && !validateProject(initial).some(issue => issue.severity === 'error')) void compute(initial, true); - if (!saved && untouched && !earlyInteraction.current && !currentPanel.current && !hasSeenLearningWelcome()) setPanel('learn'); + } + const entry = new URL(window.location.href), requestedLesson = entry.searchParams.get('learn') === '1'; + if (untouched && !earlyInteraction.current && !currentPanel.current && (requestedLesson || (!saved && (!resume || resume.projectId !== initial.id) && !hasSeenLearningWelcome()))) { + setPanel('learn'); + if (requestedLesson) { entry.searchParams.delete('learn'); window.history.replaceState(null, '', entry); } } }).catch(error => { if (!alive) return; @@ -986,6 +992,7 @@ export default function App() { : null; const draftSummary = layoutOnly ? tm.layoutMode : normalBudget ? sx.quickSummary(ease.duration(project.model.horizon), number(normalBudget.groups[0].arrivalsPerRun), normalBudget.groups[0].stochastic, project.model.resources.length) : sx.reviewInputs; return
{ if (!storageReady) earlyInteraction.current = true; }} onKeyDownCapture={() => { if (!storageReady) earlyInteraction.current = true; }}> + { event.preventDefault(); document.querySelector(learningFocused ? '[data-learning-heading]' : '#workspace')?.focus(); }}>{learningFocused ? lm.skipToLesson : m.skipToWorkspace}
{ event.preventDefault(); setPanel(null); }}>{m.queueLab}.

{sx.localProject}
@@ -1004,7 +1011,7 @@ export default function App() { {!panel && learningFocused &&
{learningCoach}
} -
+
{pendingEdits &&

{sx.pendingEdits}

} {revisionExhausted &&
{m.revisionLimitReached}
} @@ -1037,7 +1044,7 @@ export default function App() {
-

{m.buildingYourLittleWorld}

}> setPlaying(false)} project={visualProject} selectedFacilityId={facilitySelection} onSelectFacility={selectFacility} onMoveFacility={moveFacility} onDropAsset={droppedLibraryAsset} quality={quality} frame={frame} selected={selected} selectedIds={selection.ids} reducedMotion={reducedMotion} moving={moving && !hasErrors && !revisionExhausted && !learningFocused} editable={!hasErrors && !revisionExhausted && !learningFocused} flow={sceneFlow} onSelect={selectFromWorld} onMove={moveSelected} onCapacityChange={changeCapacity} onEditNode={editSceneNode} />
+

{worldSceneMessages.previewUnavailable}

}>

{m.buildingYourLittleWorld}

}> setPlaying(false)} project={visualProject} selectedFacilityId={facilitySelection} onSelectFacility={selectFacility} onMoveFacility={moveFacility} onDropAsset={droppedLibraryAsset} quality={quality} frame={frame} selected={selected} selectedIds={selection.ids} reducedMotion={reducedMotion} moving={moving && !hasErrors && !revisionExhausted && !learningFocused} editable={!hasErrors && !revisionExhausted && !learningFocused} flow={sceneFlow} onSelect={selectFromWorld} onMove={moveSelected} onCapacityChange={changeCapacity} onEditNode={editSceneNode} />
{selectedStep?.label ?? m.selectionEmpty}
{layoutOnly ?

{tm.layoutHint}

: <> diff --git a/src/features/EditFields.tsx b/src/features/EditFields.tsx index 055d36d..c3915ae 100644 --- a/src/features/EditFields.tsx +++ b/src/features/EditFields.tsx @@ -34,7 +34,11 @@ export function NumberField({ label, value, onChange, contextKey, min, max, step // field uses the shorter reading format; focusing never creates a model edit. const [draft, setDraft] = useState(() => String(value)); const [error, setError] = useState(''); const currentContext = useRef(contextKey); currentContext.current = contextKey; - useEffect(() => { setDraft(String(value)); setError(''); }, [value, contextKey]); + const previous = useRef({ value, contextKey }); + useEffect(() => { + if (Object.is(previous.current.value, value) && previous.current.contextKey === contextKey) return; + previous.current = { value, contextKey }; setDraft(String(value)); setError(''); + }, [value, contextKey]); const parsed = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(draft.trim()) ? Number(draft) : NaN; const dirty = draft !== String(value); const compactReading = !dirty && format(value) !== draft; diff --git a/src/features/LearningGuide.tsx b/src/features/LearningGuide.tsx index df36b69..a4237bb 100644 --- a/src/features/LearningGuide.tsx +++ b/src/features/LearningGuide.tsx @@ -56,7 +56,7 @@ export function LearningCoach(props: CoachProps) { return