diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml new file mode 100644 index 0000000..1fe47f9 --- /dev/null +++ b/.github/workflows/adapters.yml @@ -0,0 +1,16 @@ +name: adapter example +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + example: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun scripts/check-adapters.ts --install-system-deps diff --git a/README.md b/README.md index 5725c0d..9681efe 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ all the same. ```sh bunx rig-parts propose --parts work/rig --source inputs/painting.png --out work --keypoints keypoints.json -bun ../getting-started/scripts/take.ts work/proposal.json config.json bones meshes regions motion +bun ../getting-started/scripts/take.ts work/proposal.json config.json bones meshes regions motion limbs ``` `propose` drafts the bones, the meshes and an idle motion; `take.ts` copies them into the @@ -168,6 +168,7 @@ names `propose` gave this rig. ```sh bun ../getting-started/scripts/wave.ts keypoints.json wave.pose.json +bunx rig-pose clean --pose wave.pose.json --out wave.pose.clean.json cat > map.json <<'EOF' { "spec": "rig-pose-map/1", @@ -179,7 +180,7 @@ cat > map.json <<'EOF' "translate": { "bone": "hip", "joint": "mid_hip", "scale": { "pose": ["mid_hip", "neck"], "rig": ["hip", "neck"] } } } EOF -bunx rig-pose retarget --rig out/rig --map map.json --pose wave.pose.json --out wave.motion.json --name wave --profile spine-html --build wave-build +bunx rig-pose retarget --rig out/rig --map map.json --pose wave.pose.clean.json --out wave.motion.json --name wave --profile spine-html --build wave-build ``` You should see `73 frame(s), 3 s at 24 fps`, `GATE … green`, and @@ -202,8 +203,8 @@ to `idle`. Ctrl+C stops the server. That's the whole chain. The page is ## Use what you made -Three places the rig can go from here. Each reads files the steps above already wrote; -the sources behind every line are in [docs/REFERENCE.md](docs/REFERENCE.md#use-what-you-made). +Four places the rig can go from here. Each reads files the steps above already wrote; +the original three are measured in [docs/REFERENCE.md](docs/REFERENCE.md#use-what-you-made). ### On a web page, with `@rig-c/play` @@ -220,6 +221,48 @@ Licence: `@rig-c/play` is MIT. It links nothing from Esoteric Software and poses rig-c's own core, so no Spine licence is involved: the Spine terms turn on integrating a Spine Runtime, and this path integrates none. +### Inside a game engine or renderer you already use + +Keep your game in PixiJS, Phaser or Three.js, and load the same `wave-build/` with +[rig-c's adapters](https://github.com/rig-c/adapters). No Spine runtime. The packages +are MIT, currently 0.x; their APIs may change. + +For PixiJS 8, install this in `my-rig`: + +```sh +npm install @rig-c/pixi pixi.js @rig-c/play +``` + +Put this in your app's JavaScript entry point, serve `wave-build/` beside the page, +and you have the wave from step 10 inside a Pixi canvas: + +```js +import { Application } from 'pixi.js'; +import { loadModel } from '@rig-c/play'; +import { PixiRig } from '@rig-c/pixi'; + +const app = new Application(); +await app.init({ width: 800, height: 600, backgroundAlpha: 0 }); +document.body.appendChild(app.canvas); +const assets = await loadModel({ modelUrl: '/wave-build/skeleton.model.json' }); +const rig = new PixiRig(assets, { ticker: app.ticker }); +rig.play('wave'); +const box = rig.bounds(); +const scale = Math.min(760 / box.width, 560 / box.height); +rig.scale.set(scale); +rig.position.set(400 - (box.x + box.width / 2) * scale, + 300 + (box.y + box.height / 2) * scale); +app.stage.addChild(rig); +// On removal: rig.destroy(); assets.dispose(); app.destroy(); +``` + +The fit places the rig's upward-growing coordinates inside Pixi's canvas. For the other +hosts, start from the [Phaser 4.2+ example](https://github.com/rig-c/adapters/tree/main/packages/phaser#minimal-usage) +or the [Three.js example](https://github.com/rig-c/adapters/tree/main/packages/three#minimal-usage). +Keep the model document and its image pages together. The +[example check](scripts/check-adapters.ts) installs the published packages and runs this +exact snippet on the guide's build in a headless browser. + ### In the Spine editor, to refine by hand For a person to edit what the tools drafted. The build wrote Spine 4.3 skeleton data: @@ -258,9 +301,6 @@ the Spine Editor License Agreement*, or otherwise *on the condition that each us product obtains their own Spine Editor license* and the product carries the Runtimes licence and copyright notice. The licence itself is at [esotericsoftware.com/spine-runtimes-license](https://esotericsoftware.com/spine-runtimes-license). -rig-c's own engine adapters, which would need no Spine runtime, are planned at -[rig-c/adapters](https://github.com/rig-c/adapters) (PixiJS, Phaser, Three.js) and are not -built yet. ## Make your own character diff --git a/scripts/check-adapters.ts b/scripts/check-adapters.ts new file mode 100644 index 0000000..f735b39 --- /dev/null +++ b/scripts/check-adapters.ts @@ -0,0 +1,287 @@ +// Run from any directory: bun scripts/check-adapters.ts [--profile-file path | --profile-ref ref] +// All package installs and generated builds live in a fresh temporary directory. +// CI adds --install-system-deps for its disposable runner; ordinary runs only download Chromium. +import assert from 'node:assert/strict'; +import { cpSync, mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; + +const root = resolve(import.meta.dir, '..'); +const readme = readFileSync(join(root, 'README.md'), 'utf8'); +const arg = (key: string) => { const at = process.argv.indexOf(key); return at < 0 ? undefined : process.argv[at + 1]; }; +const profileRef = arg('--profile-ref') ?? 'main'; +const profilePath = arg('--profile-file'); +const profileUrl = `https://raw.githubusercontent.com/rig-c/.github/${encodeURIComponent(profileRef)}/profile/README.md`; +const profile = profilePath ? readFileSync(resolve(profilePath), 'utf8') : await get(profileUrl); +const temp = mkdtempSync(join(tmpdir(), 'rig-c-onboarding-')); +const consumer = join(temp, 'my-rig'); +mkdirSync(consumer); +cpSync(join(root, 'scripts'), join(temp, 'getting-started/scripts'), { recursive: true }); +const reports: Record = {}; +let tutorialOrigin: string | undefined; +let stopTutorial = () => {}; + +function run(command: string[], cwd: string) { + console.log(`[command] ${command.join(' ')}`); + const p = Bun.spawnSync(command, { cwd, stdout: 'inherit', stderr: 'inherit', env: { ...process.env, npm_config_jobs: '4', BUN_CONFIG_MAX_HTTP_REQUESTS: '4' } }); + assert.equal(p.exitCode, 0, `COMMAND: ${command.join(' ')} exited ${p.exitCode}`); +} +function blocks(source: string, language: string): string[] { + return [...source.matchAll(new RegExp('```' + language + '\\n([\\s\\S]*?)```', 'g'))].map(m => m[1].trim()); +} +function step(number: number) { + const match = readme.match(new RegExp('### ' + number + '\\. [^\\n]+\\n([\\s\\S]*?)(?=\\n### |\\n## )')); + assert(match, `GUIDE_STEP: missing step ${number}`); + return match[1]; +} +async function get(url: string) { + const response = await fetch(url, { signal: AbortSignal.timeout(45000), headers: { 'User-Agent': 'rig-c-onboarding-link-check' } }); + assert(response.ok, `LINK_HTTP: ${url} returned ${response.status}`); + return response.text(); +} +function urls(source: string): string[] { + const found = [ + ...[...source.matchAll(/!?\[[^\]\n]*\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g)].map(m => m[1]), + ...[...source.matchAll(/(?:href|src)="([^"]+)"/g)].map(m => m[1]), + ...[...source.matchAll(/^\[[^\]]+\]:\s*(\S+)/gm)].map(m => m[1]), + ...[...source.matchAll(/<(https?:\/\/[^>]+)>/g)].map(m => m[1]), + ]; + return [...new Set(found)]; +} +const slug = (s: string) => s.toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\p{L}\p{N}_\-\s]/gu, '').replace(/\s/g, '-'); +function anchorExists(source: string, anchor: string) { + const seen = new Map(); + return source.split('\n').some(line => { + const heading = line.match(/^#{1,6}\s+(.+?)\s*#*$/); + if (!heading) return false; + const base = slug(heading[1]); const n = seen.get(base) ?? 0; seen.set(base, n + 1); + return (n ? `${base}-${n}` : base) === decodeURIComponent(anchor); + }); +} +function tutorialUrl(link: string) { + const url = new URL(link); + assert(tutorialOrigin, 'TUTORIAL_SERVER: owned step-10 server is not running'); + assert(url.protocol === 'http:' && url.hostname === 'localhost' && url.port === '3000' && url.pathname === '/', `TUTORIAL_URL: unexpected tutorial endpoint ${link}`); + assert.equal(url.searchParams.get('model'), 'wave-build/skeleton.model.json', 'TUTORIAL_MODEL: expected the step-10 build'); + assert.equal(url.searchParams.get('anim'), 'wave', 'TUTORIAL_ANIMATION: expected wave'); + return tutorialOrigin + url.pathname + url.search; +} +const branchCache = new Map>(); +function defaultBranch(repo: string): Promise { + let pending = branchCache.get(repo); + if (!pending) { + pending = (async () => { + const metadata = JSON.parse(await get(`https://api.github.com/repos/${repo}`)) as { default_branch?: unknown }; + assert(typeof metadata.default_branch === 'string' && metadata.default_branch.length > 0, `LINK_BRANCH: ${repo} metadata has no default_branch`); + return metadata.default_branch; + })(); + branchCache.set(repo, pending); + } + return pending; +} +async function checkLink(link: string, source: string, repo: string, directory: string, local: boolean) { + if (link.startsWith('#')) { assert(anchorExists(source, link.slice(1)), `LINK_ANCHOR: ${repo} ${link}`); return; } + if (!/^https?:/.test(link)) { + const [path, anchor] = link.split('#'); + const content = local ? readFileSync(resolve(root, directory, path), 'utf8') : await get(`https://raw.githubusercontent.com/${repo}/${profileRef}/${directory}/${path}`); + if (anchor) assert(anchorExists(content, anchor), `LINK_ANCHOR: ${repo} ${link}`); + return; + } + if (new URL(link).hostname === 'localhost') { await get(tutorialUrl(link)); return; } + const url = new URL(link); const anchor = url.hash.slice(1); url.hash = ''; + const html = await get(url.href); + // GitHub renders README headings dynamically; check the source Markdown too. + if (anchor && url.hostname === 'github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length === 2 || parts[2] === 'tree' || parts[2] === 'blob') { + const branch = parts.length === 2 ? await defaultBranch(`${parts[0]}/${parts[1]}`) : parts[3]; + const file = parts.length === 2 ? 'README.md' : parts.slice(4).join('/') + (parts[2] === 'tree' ? '/README.md' : ''); + const md = await get(`https://raw.githubusercontent.com/${parts[0]}/${parts[1]}/${branch}/${file}`); + assert(anchor === 'readme' || anchorExists(md, anchor), `LINK_ANCHOR: ${link}`); + } else assert(html.includes(`id="${anchor}"`) || html.includes(`id="user-content-${anchor}"`), `LINK_ANCHOR: ${link}`); + } +} +async function links() { + const failures: string[] = []; + const baselineGuide = await get('https://raw.githubusercontent.com/rig-c/getting-started/4715d205c2ee47fcc49b2675f737d8dd8ca9c062/README.md'); + const baselineProfile = await get('https://raw.githubusercontent.com/rig-c/.github/93be3f5e11a07e17fb70382a9a074a93bcb06238/profile/README.md'); + const baseline = new Map([['rig-c/getting-started', new Set(urls(baselineGuide))], ['rig-c/.github', new Set(urls(baselineProfile))]]); + let count = 0; + for (const doc of [{ text: readme, repo: 'rig-c/getting-started', directory: '', local: true }, { text: profile, repo: 'rig-c/.github', directory: 'profile', local: false }]) { + const items = urls(doc.text); + // Network checks are bounded to four concurrent requests. + for (let at = 0; at < items.length; at += 4) await Promise.all(items.slice(at, at + 4).map(async link => { + try { await checkLink(link, doc.text, doc.repo, doc.directory, doc.local); console.log(`[link:PASS] ${doc.repo}: ${link}`); } + catch (error) { failures.push(`${baseline.get(doc.repo)?.has(link) ? 'preexisting' : 'added'} ${doc.repo}: ${link}: ${String(error)}`); console.error(`[link:FAIL] ${failures.at(-1)}`); } + count++; + })); + } + reports.links = { count, failures }; + return failures; +} +try { + const engine = readme.split('### Inside a game engine or renderer you already use\n')[1]?.split('\n### ')[0]; + assert(engine, 'SNIPPET: missing adapter destination'); + const snippet = blocks(engine, 'js')[0]; assert(snippet, 'SNIPPET: missing JavaScript'); + reports.snippetSha256 = createHash('sha256').update(snippet).digest('hex'); + const row = profile.split('\n').find(line => line.startsWith('| 5 |') && line.includes('adapters')); + assert(row, 'PROFILE: adapters row missing (use --profile-file or --profile-ref for the pending profile change)'); + const installs = [...row.matchAll(/`(npm install [^`]+)`/g)].map(m => m[1]); + assert.equal(installs.length, 3, 'PROFILE: expected three install commands'); + assert(installs.includes(blocks(engine, 'sh')[0]), 'INSTALL: guide install differs from profile'); + for (let i = 0; i < installs.length; i++) { + const dir = i === 0 ? consumer : join(temp, `host-${i}`); mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), '{"private":true,"type":"module"}\n'); + run(installs[i].split(' '), dir); + run(['npm', 'ls', '--depth=0'], dir); + const graph = Bun.spawnSync(['npm', 'ls', '--all', '--json'], { cwd: dir }); + assert.equal(graph.exitCode, 0, 'INSTALL_TREE: npm ls failed'); + assert(!/@esotericsoftware|spine-core/.test(graph.stdout.toString()), 'INSTALL_TREE: Spine runtime present'); + } + console.log('[bar:install] three literal published install lines PASS; Spine runtime absent'); + // Follow the guide's documented no-Python route, through the build step 10 plays. + const install = blocks(step(1), 'sh')[0].split('\n').find(line => line.startsWith('bun add ')); + assert(install, 'GUIDE_INSTALL: missing bun add'); run(install.split(' '), consumer); + for (const n of [2, 3, 4, 5]) run(['bash', '-eu', '-o', 'pipefail', '-c', blocks(step(n), 'sh')[0]], consumer); + const keypoints = blocks(readFileSync(join(root, 'docs/KEYPOINTS.md'), 'utf8'), 'json')[0]; + assert(keypoints, 'KEYPOINTS: hand-placed guide example absent'); JSON.parse(keypoints); + writeFileSync(join(consumer, 'keypoints.json'), keypoints); + const beforeDraft = readFileSync(join(consumer, 'config.json'), 'utf8'); + for (const n of [7, 8]) run(['bash', '-eu', '-o', 'pipefail', '-c', blocks(step(n), 'sh')[0]], consumer); + // Revert the real field-copy repair in a private README copy. Its command, + // over the same proposal and installed packages, must reproduce the refusal. + const take = blocks(step(7), 'sh')[0].split('\n').find(line => line.includes('/scripts/take.ts ')); + assert(take && take.endsWith(' limbs'), 'GUIDE_LIMBS: step 7 must retain proposed limbs'); + const revertedGuide = readme.replace(take, take.slice(0, -' limbs'.length)); + const revertedPath = join(temp, 'README-with-limbs-reverted.md'); writeFileSync(revertedPath, revertedGuide); + const revertedStep7 = readFileSync(revertedPath, 'utf8').match(/### 7\. [^\n]+\n([\s\S]*?)(?=\n### )/); + assert(revertedStep7, 'GUIDE_CONTROL: missing reverted step 7'); + const revertedTake = blocks(revertedStep7[1], 'sh')[0].split('\n').find(line => line.includes('/scripts/take.ts ')); + assert(revertedTake && !revertedTake.endsWith(' limbs'), 'GUIDE_CONTROL: limbs revert did not take effect'); + writeFileSync(join(consumer, 'without-limbs.config.json'), beforeDraft); + run(['bash', '-eu', '-o', 'pipefail', '-c', revertedTake.replace(' config.json ', ' without-limbs.config.json ')], consumer); + const rejectedCommand = blocks(step(8), 'sh')[0].replace('--config config.json', '--config without-limbs.config.json').replace('--out out', '--out without-limbs-out'); + const rejected = Bun.spawnSync(['bash', '-eu', '-o', 'pipefail', '-c', rejectedCommand], { cwd: consumer, env: process.env }); + const rejectedLog = new TextDecoder().decode(rejected.stdout) + new TextDecoder().decode(rejected.stderr); + console.log(`[command:expected-refusal] ${rejectedCommand}\n${rejectedLog}`); + assert.equal(rejected.exitCode, 1, 'GUIDE_CONTROL: reverted limbs must refuse the build'); + const expectedParts = ['handwear_l', 'handwear_r', 'legwear']; + const expectedRegions = ['upperarm_r', 'forearm_r', 'upperarm_l', 'forearm_l', 'thigh_r', 'shin_r', 'thigh_l', 'shin_l']; + for (const name of expectedParts) assert(rejectedLog.includes(`CONFIG_PART_ATTACHED: part "${name}"`), `GUIDE_CONTROL: missing CONFIG_PART_ATTACHED ${name}`); + for (const name of expectedRegions) assert(rejectedLog.includes(`CONFIG_NAME_RESOLVES: config.regions.${name}`), `GUIDE_CONTROL: missing CONFIG_NAME_RESOLVES ${name}`); + reports.limbsControl = { exitCode: rejected.exitCode, parts: expectedParts, regions: expectedRegions }; + console.log('[bar:guide-limbs] current packages: repaired step 8 exit 0; exact README limbs revert reproduces 11 named refusals PASS'); + run(['bash', '-eu', '-o', 'pipefail', '-c', blocks(step(9), 'sh')[0]], consumer); + const retarget = blocks(step(9), 'sh')[0].split('\n').find(line => line.startsWith('bunx rig-pose retarget ')); + assert(retarget && retarget.includes('--pose wave.pose.clean.json'), 'GUIDE_CLEAN: retarget must read the cleaned sequence'); + const rawRetarget = retarget.replace('--pose wave.pose.clean.json', '--pose wave.pose.json').replace('--out wave.motion.json', '--out without-clean.motion.json').replace('--build wave-build', '--build without-clean-build'); + const uncleaned = Bun.spawnSync(['bash', '-eu', '-o', 'pipefail', '-c', rawRetarget], { cwd: consumer, env: process.env }); + const uncleanedLog = new TextDecoder().decode(uncleaned.stdout) + new TextDecoder().decode(uncleaned.stderr); + console.log(`[command:expected-refusal] ${rawRetarget}\n${uncleanedLog}`); + assert.equal(uncleaned.exitCode, 1, 'GUIDE_CONTROL: raw sequence must refuse retarget'); + assert(uncleanedLog.includes('POSE_NOT_CLEANED: wave.pose.json'), 'GUIDE_CONTROL: missing POSE_NOT_CLEANED'); + reports.cleanControl = { exitCode: uncleaned.exitCode, rule: 'POSE_NOT_CLEANED' }; + console.log('[bar:guide-clean] cleaned sequence retarget exit 0; exact original raw-sequence command refused POSE_NOT_CLEANED PASS'); + reports.modelSha256 = createHash('sha256').update(readFileSync(join(consumer, 'wave-build/skeleton.model.json'))).digest('hex'); + // Verification tools are separate from each empty consumer installation. + const tools = join(temp, 'tools'); mkdirSync(tools); writeFileSync(join(tools, 'package.json'), '{"private":true,"type":"module"}'); + run(['npm', 'install', '--no-audit', '--no-fund', 'playwright@1.58.2'], tools); + run(['bun', join(tools, 'node_modules/playwright/cli.js'), 'install', ...(process.argv.includes('--install-system-deps') ? ['--with-deps'] : []), 'chromium'], tools); + const { chromium } = await import(join(tools, 'node_modules/playwright/index.mjs')); + // Run the documented step-10 server ourselves. Remap its advertised port only; + // never let an unrelated localhost process satisfy a tutorial link. + const tutorial = Bun.spawn(['bun', '../getting-started/scripts/serve.ts', '--port', '0'], { cwd: consumer, stdout: 'pipe', stderr: 'inherit' }); + stopTutorial = () => { tutorial.kill(); }; + const startup = async () => { + let text = ''; + for await (const chunk of tutorial.stdout) { + text += new TextDecoder().decode(chunk); + const match = text.match(/http:\/\/localhost:(\d+)\//); + if (match) return `http://127.0.0.1:${match[1]}`; + } + throw new Error(`TUTORIAL_SERVER: exited before listening: ${text}`); + }; + tutorialOrigin = await Promise.race([startup(), new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error('TUTORIAL_SERVER: startup timeout')), 30000); timer.unref(); })]); + console.log(`[tutorial:owned-server] ${tutorialOrigin}`); + const linkFailures = await links(); + const observation = ` +const readFrame = () => { + app.renderer.render({ container: app.stage }); + const gl = app.renderer.gl; + if (!gl) throw new Error('FRAME_CONTEXT: expected native WebGL canvas'); + const pixels = new Uint8Array(gl.drawingBufferWidth * gl.drawingBufferHeight * 4); + gl.readPixels(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + let painted = 0; for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) painted++; + return { painted, pixels: Array.from(pixels), pose: JSON.stringify(rig.player.posedFrame()) }; +}; +window.exampleControls = { hide: () => { rig.visible = false; }, freeze: () => app.ticker.stop() }; +window.exampleCheck = async () => { + const first = readFrame(); + await new Promise(resolve => setTimeout(resolve, 700)); + const second = readFrame(); + let changed = 0; for (let i = 0; i < first.pixels.length; i++) if (first.pixels[i] !== second.pixels[i]) changed++; + return { first: first.painted, second: second.painted, changed, advanced: first.pose !== second.pose }; +};`; + async function bundle(code: string, name: string) { + const file = join(consumer, `${name}.js`); writeFileSync(file, code); + const result = await Bun.build({ entrypoints: [file], target: 'browser', outdir: join(consumer, name), naming: 'entry.js' }).catch((error: unknown) => { throw new Error(`SNIPPET_IMPORT: ${name}: ${String(error)}`, { cause: error }); }); + assert(result.success, `SNIPPET_IMPORT: ${name}: ${result.logs.join('\n')}`); + return `/${name}/entry.js`; + } + await bundle(snippet, 'literal-snippet'); + const entry = await bundle(snippet + observation, 'example'); + // A broken import must be rejected by the exact compiler used for the snippet. + await assert.rejects(() => bundle("import '@rig-c/does-not-exist-onboarding-control';", 'broken-import'), /SNIPPET_IMPORT/); + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) { + const pathname = new URL(req.url).pathname; + if (pathname === '/') return new Response(``, { headers: { 'content-type': 'text/html' } }); + const file = resolve(consumer, '.' + pathname); + if (!file.startsWith(consumer + '/')) return new Response('outside consumer', { status: 403 }); + const body = Bun.file(file); + if (!await body.exists()) return new Response('not found', { status: 404 }); + return new Response(body); + } }); + const browser = await chromium.launch({ args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }).catch((error: Error) => { server.stop(true); throw error; }); + try { + const tutorialLinks = urls(readme).filter(link => link.startsWith('http://localhost:')); + assert.equal(tutorialLinks.length, 1, 'TUTORIAL_LINK: expected one step-10 endpoint'); + const tutorialPage = await browser.newPage(); + const tutorialErrors: string[] = []; + tutorialPage.on('pageerror', (error: Error) => tutorialErrors.push(String(error))); + await tutorialPage.goto(tutorialUrl(tutorialLinks[0])); + await tutorialPage.waitForFunction('window.rigPlayer && document.querySelector("#anim").value === "wave"', null, { timeout: 60000 }); + assert(await tutorialPage.evaluate('window.rigPlayer.posedFrame().order.length > 0'), 'TUTORIAL_FRAME: model has no drawn slots'); + assert.deepEqual(tutorialErrors, [], 'TUTORIAL_BROWSER: step-10 page failed'); + await tutorialPage.close(); + console.log('[bar:tutorial] owned documented server loaded requested wave-build model and wave animation PASS'); + const page = await browser.newPage(); const errors: string[] = []; + page.on('pageerror', (error: Error) => errors.push(String(error))); + page.on('requestfailed', (request: { url(): string; failure(): { errorText: string } | null }) => errors.push(`${request.url()}: ${request.failure()?.errorText}`)); + await page.goto(`http://127.0.0.1:${server.port}`); + await page.waitForFunction('typeof window.exampleCheck === "function"', null, { timeout: 60000 }); + const result = await page.evaluate('window.exampleCheck()'); + function frameGate(value: { first: number; second: number; changed: number; advanced: boolean }) { + assert(value.first > 0 && value.second > 0, 'FRAME_EMPTY: expected native alpha pixels'); + assert(value.changed > 0 && value.advanced, 'FRAME_FROZEN: expected ticker-driven wave movement'); + } + frameGate(result); + await page.evaluate('window.exampleControls.hide()'); + const empty = await page.evaluate('window.exampleCheck()'); + assert.throws(() => frameGate(empty), /FRAME_EMPTY/); + await page.reload(); + await page.waitForFunction('typeof window.exampleCheck === "function"', null, { timeout: 60000 }); + await page.evaluate('window.exampleControls.freeze()'); + const frozen = await page.evaluate('window.exampleCheck()'); + assert.throws(() => frameGate(frozen), /FRAME_FROZEN/); + reports.controls = { empty, frozen }; + await assert.rejects(() => get(`http://127.0.0.1:${server.port}/missing-link-control`), /LINK_HTTP/); + assert.deepEqual(errors, [], `BROWSER: ${errors.join('\n')}`); + reports.frame = result; + console.log(`[bar:frame] ${JSON.stringify(result)} PASS`); + console.log('[bar:controls] broken import, empty frame, frozen frame, dead link all rejected PASS'); + } finally { await browser.close(); server.stop(true); } + console.log(`[evidence] ${JSON.stringify(reports)}`); + assert.equal(linkFailures.length, 0, `LINKS: ${linkFailures.length} unresolved links (reported above; unrelated content is not rewritten)`); + console.log('[bar:summary] published installs, step-10 build, literal snippet, native pixels, animation, links PASS'); +} finally { stopTutorial(); rmSync(temp, { recursive: true, force: true }); }