diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 00000000..82ab4f88 --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,313 @@ +name: Demo + +on: + workflow_call: + inputs: + digest: + type: string + required: true + version: + type: string + required: true + secrets: + CLOUDFLARE_API_TOKEN: + required: false + CLOUDFLARE_ACCOUNT_ID: + required: false + workflow_dispatch: + inputs: + version: + description: Published version, for example 1.18.0 + type: string + required: true + +permissions: + contents: read + +# The release's demo job holds the same group. Runs that overlap would promote out +# of order. A called workflow gets a group of its own, so it never waits on its caller. +concurrency: + group: ${{ github.event_name == 'workflow_dispatch' && 'stackyard-demo-deploy' || format('demo-{0}', github.run_id) }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + WRANGLER_VERSION: 4.151.3 + +jobs: + # Dev dependencies run here. Never give this job a token. + build: + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read + steps: + # The version reaches Wrangler's command line below. + - name: Check the version + env: + VERSION: ${{ inputs.version }} + run: | + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::$VERSION is not a stable version such as 1.18.0" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + + - name: Install dev tooling + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: playwright-${{ runner.os }}- + + - name: Install the browser + run: npx playwright install --with-deps chromium + + - name: Start the image in demo mode + env: + DIGEST: ${{ inputs.digest }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ -n "$DIGEST" ]; then + IMAGE="ghcr.io/sandobserver/stackyard@${DIGEST}" + else + IMAGE="ghcr.io/sandobserver/stackyard:${VERSION}" + fi + docker pull "$IMAGE" + echo "Demo from $(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE")" + docker run -d --name stackyard-demo -e DEMO_MODE=true -p 8740:80 "$IMAGE" + for i in $(seq 1 60); do + if curl -fsS http://127.0.0.1:8740/health >/dev/null 2>&1; then echo "up after ${i}s"; exit 0; fi + sleep 1 + done + echo "::error::the demo container never became healthy" + docker logs stackyard-demo + exit 1 + + - name: Copy the web root + run: docker cp stackyard-demo:/usr/share/nginx/html "$RUNNER_TEMP/webroot" + + - name: Tour the container + run: node scripts/demo/tour.js discover http://127.0.0.1:8740 "$RUNNER_TEMP/tour.json" + + - name: Record the demo + run: node scripts/demo/record.js http://127.0.0.1:8740 "$RUNNER_TEMP/tour.json" "$RUNNER_TEMP/webroot" "$RUNNER_TEMP/recording.json" 10 + + - name: Build the static demo + run: | + set -euo pipefail + node scripts/demo/build.js "$RUNNER_TEMP/webroot" "$RUNNER_TEMP/recording.json" demo-site + mkdir demo-recording + cp "$RUNNER_TEMP/tour.json" "$RUNNER_TEMP/recording.json" demo-recording/ + + - name: Container logs on failure + if: failure() + run: docker logs stackyard-demo + + - name: Upload the static demo + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: demo-site + path: demo-site/ + retention-days: 7 + + - name: Upload the recording + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: demo-recording + path: demo-recording/ + retention-days: 7 + + upload: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: {} + outputs: + url: ${{ steps.version.outputs.url }} + id: ${{ steps.version.outputs.id }} + steps: + - name: Check for Cloudflare credentials + id: creds + env: + TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo 'CLOUDFLARE_API_TOKEN is not set. Nothing uploaded.' + echo 'set=false' >> "$GITHUB_OUTPUT" + else + echo 'set=true' >> "$GITHUB_OUTPUT" + fi + + # The Wrangler config comes from the repository, never from the build job. + - name: Checkout the Wrangler config + if: steps.creds.outputs.set == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: repo + sparse-checkout: scripts/demo/wrangler.jsonc + sparse-checkout-cone-mode: false + + - name: Download the static demo + if: steps.creds.outputs.set == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: demo-site + path: upload/site + + # The build job runs dev dependencies. Wrangler runs in this folder with the + # token, so it must hold web files only: no package, config or link. + - name: Check the site holds web files only + if: steps.creds.outputs.set == 'true' + run: | + set -euo pipefail + bad=$(find upload/site \( -type l -o -name '.*' -o -name node_modules -o -name 'package*.json' \ + -o -name 'wrangler.*' -o -name '_worker.js' -o \( -type f \ + ! -regex '.*\.\(html\|js\|css\|json\|png\|jpg\|jpeg\|webp\|svg\|ico\|woff2\)$' \ + ! -name _headers ! -name _redirects \) \) -print) + if [ -n "$bad" ]; then + echo "::error::the site holds files that are not web files:" + printf '%s\n' "$bad" + exit 1 + fi + cp repo/scripts/demo/wrangler.jsonc upload/ + rm -rf repo + + # Wrangler cannot upload a version of a Worker that does not exist yet. The + # first run creates it with a placeholder page, so nothing untested goes live. + - name: Check the Worker exists + id: worker + if: steps.creds.outputs.set == 'true' + env: + TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + # The header comes from stdin, so the token never appears in a process list. + code=$(printf 'Authorization: Bearer %s' "$TOKEN" | curl --silent --output /dev/null \ + --write-out '%{http_code}' --max-time 30 --header @- \ + "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/workers/scripts/stackyard-demo/settings") + case "$code" in + 200) echo 'exists=true' >> "$GITHUB_OUTPUT" ;; + 404) echo 'exists=false' >> "$GITHUB_OUTPUT" ;; + *) echo "::error::Cloudflare answered HTTP $code"; exit 1 ;; + esac + + - name: Prepare the placeholder + if: steps.creds.outputs.set == 'true' && steps.worker.outputs.exists == 'false' + run: | + set -euo pipefail + mkdir -p placeholder/site + printf 'Stackyard demo

The demo is being prepared.

\n' > placeholder/site/index.html + printf '/ /index.html 200\n' > placeholder/site/_redirects + cp upload/wrangler.jsonc placeholder/ + + - name: Create the Worker + if: steps.creds.outputs.set == 'true' && steps.worker.outputs.exists == 'false' + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: ${{ env.WRANGLER_VERSION }} + workingDirectory: placeholder + command: deploy + + - name: Upload a version + id: wrangler + if: steps.creds.outputs.set == 'true' + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: ${{ env.WRANGLER_VERSION }} + workingDirectory: upload + command: versions upload --tag v${{ inputs.version }} --message "Stackyard ${{ inputs.version }}" + + - name: Read the version + id: version + if: steps.creds.outputs.set == 'true' + env: + OUT: ${{ steps.wrangler.outputs.command-output }} + run: | + set -euo pipefail + id=$(printf '%s\n' "$OUT" | sed -n 's/.*Worker Version ID: *\([0-9a-f-]\{36\}\).*/\1/p' | head -1) + url=$(printf '%s\n' "$OUT" | sed -n 's/.*Version Preview URL: *\(https:[^ ]*\).*/\1/p' | head -1) + if [ -z "$id" ] || [ -z "$url" ]; then + echo '::error::the upload printed no version ID or preview address' + exit 1 + fi + echo "id=$id" >> "$GITHUB_OUTPUT" + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "Version $id previews at $url" + + # Dev dependencies run here. Never give this job a token. + verify: + needs: upload + if: needs.upload.outputs.url != '' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + + - name: Install dev tooling + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: playwright-${{ runner.os }}- + + - name: Install the browser + run: npx playwright install --with-deps chromium + + - name: Download the recording + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: demo-recording + path: demo-recording + + - name: Tour the preview + env: + URL: ${{ needs.upload.outputs.url }} + run: node scripts/demo/tour.js verify "$URL" demo-recording/tour.json demo-recording/recording.json + + # Keep verify in needs. Without it, an untested version goes live. + promote: + needs: [upload, verify] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: {} + steps: + - name: Deploy the version + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: ${{ env.WRANGLER_VERSION }} + command: versions deploy ${{ needs.upload.outputs.id }}@100 --name stackyard-demo --yes --message "Stackyard ${{ inputs.version }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c0b7707..04741154 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -400,3 +400,19 @@ jobs: exit 1 fi echo 'Documentation site rebuild requested.' + + demo: + needs: [build-and-push, release-page] + if: needs.release-page.outputs.prerelease == 'false' + concurrency: + group: stackyard-demo-deploy + cancel-in-progress: false + permissions: + contents: read + uses: ./.github/workflows/demo.yml + with: + digest: ${{ needs.build-and-push.outputs.digest }} + version: ${{ needs.build-and-push.outputs.version }} + secrets: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/scripts/demo/build.js b/scripts/demo/build.js new file mode 100644 index 00000000..e81fdc8e --- /dev/null +++ b/scripts/demo/build.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const CHUNK_FRAMES = 60; +const MAX_HEADER_RULES = 100; +const MISSING = [503, JSON.stringify({ error: 'Not available in the live demo.', kind: 'blocked' })]; +const OUTBOUND_ROUTES = ['POST /api/widget-options/:id']; + +const { listFiles, routePattern } = require('./source'); + +const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12); + +/** @param {Array>} frames */ +function classify(frames) { + const fixed = {}; + const varying = []; + for (const key of Object.keys(frames[0] || {})) { + const first = JSON.stringify(frames[0][key]); + if (frames.every(f => JSON.stringify(f[key]) === first)) fixed[key] = frames[0][key]; + else varying.push(key); + } + return { fixed, varying }; +} + +/** @param {Array>} frames @param {string} key */ +function splitSeries(frames, key, size = CHUNK_FRAMES) { + const out = []; + for (let c = 0; c < frames.length; c += size) { + const json = JSON.stringify(frames.slice(c, c + size).map(f => f[key])); + out.push({ name: `${hash(key)}.${hash(json)}.json`, json }); + } + return out; +} + +/** The replay takes the first match. Keep the most specific routes first. + @param {{ fixed: Record, writes: Array<{ method: string, route: string, answer: [number, string] }> }} r */ +function fallbackRoutes({ fixed, writes }) { + const routes = []; + const byPath = new Map(); + for (const [key, answer] of Object.entries(fixed)) { + const [method, full] = key.split(' '); + const [pathname, query] = full.split('?'); + if (method !== 'GET' || query === undefined) continue; + if (!byPath.has(pathname)) byPath.set(pathname, []); + byPath.get(pathname).push(answer); + } + /* Only failures fall back. A result for one query is wrong for another. */ + for (const [pathname, answers] of byPath) { + if (answers.every(a => a[0] >= 400)) routes.push([{ method: 'GET', path: routePattern(pathname) }, answers[0]]); + } + const ping = writes.find(w => w.method === 'POST' && w.route === '/api/ping'); + const blocked = ping && JSON.parse(ping.answer[1]); + for (const w of writes) { + if (OUTBOUND_ROUTES.includes(`${w.method} ${w.route}`)) { + if (blocked?.code !== 'blocked.demo') throw new Error(`no recorded demo-mode refusal to answer ${w.route} with`); + const body = JSON.stringify({ error: blocked.error, kind: blocked.kind, code: blocked.code }); + routes.push([{ method: w.method, path: routePattern(w.route) }, [502, body]]); + } else { + routes.push([{ method: w.method, path: routePattern(w.route) }, w.answer]); + } + } + return routes; +} + +const RULE_HEADERS = [ + 'content-security-policy', + 'x-frame-options', + 'cache-control', + 'x-content-type-options', + 'referrer-policy', + 'permissions-policy', +]; +const TITLE = { + 'content-security-policy': 'Content-Security-Policy', + 'x-frame-options': 'X-Frame-Options', + 'cache-control': 'Cache-Control', + 'x-content-type-options': 'X-Content-Type-Options', + 'referrer-policy': 'Referrer-Policy', + 'permissions-policy': 'Permissions-Policy', +}; + +const pick = h => Object.fromEntries(RULE_HEADERS.filter(n => h[n] !== undefined).map(n => [n, h[n]])); +const same = (a, b) => JSON.stringify(pick(a)) === JSON.stringify(pick(b)); + +/* Detach each changed header first. Cloudflare joins a header that two matching + rules set, and a joined Content-Security-Policy blocks the page. */ +function diffLines(base, h) { + const lines = []; + for (const n of RULE_HEADERS) { + if (base[n] === h[n]) continue; + if (base[n] !== undefined) lines.push(`! ${TITLE[n]}`); + if (h[n] !== undefined) lines.push(`${TITLE[n]}: ${h[n]}`); + } + return lines; +} + +/** @param {Record>} files headers per file path + @param {Record>} routes headers per route that is not a file */ +function headerRules(files, routes = {}) { + /* The main page's headers are the base, so a path no rule names gets the + strictest policy, not the widgets' inline-script one. */ + if (!files['/index.html']) throw new Error('the web root has no /index.html'); + const base = pick(files['/index.html']); + const rules = [['/*', RULE_HEADERS.filter(n => base[n] !== undefined).map(n => `${TITLE[n]}: ${base[n]}`)]]; + const covered = []; + + const walk = dir => { + const inside = Object.keys(files).filter(p => p.startsWith(dir)); + const first = files[inside[0]]; + if (dir !== '/' && inside.every(p => same(files[p], first))) { + if (!same(first, base)) { + rules.push([`${dir}*`, diffLines(base, first)]); + covered.push([dir, first]); + } + return; + } + const subdirs = new Set(); + for (const p of inside) { + const rest = p.slice(dir.length); + if (rest.includes('/')) subdirs.add(`${dir}${rest.split('/')[0]}/`); + else if (!same(files[p], base)) rules.push([p, diffLines(base, files[p])]); + } + for (const d of [...subdirs].sort()) walk(d); + }; + walk('/'); + + for (const [route, h] of Object.entries(routes)) { + const group = covered.find(([dir]) => route.startsWith(dir)); + if (group) { + if (!same(group[1], h)) throw new Error(`${route} needs other headers than ${group[0]}*`); + continue; + } + if (!same(h, base)) rules.push([route, diffLines(base, h)]); + } + if (rules.length > MAX_HEADER_RULES) + throw new Error(`${rules.length} header rules; Cloudflare allows ${MAX_HEADER_RULES}`); + return rules.map(([p, lines]) => `${p}\n${lines.map(l => ` ${l}`).join('\n')}\n`).join('\n'); +} + +/** @param {string[]} routes */ +function redirectRules(routes) { + const lines = [...routes].sort().map(r => `${r} ${r.endsWith('/') ? r : `${r}/`}index.html 200`); + return `${lines.join('\n')}\n`; +} + +/** @param {string} html @param {string} src */ +function injectScript(html, src) { + const out = html.replace(/]*)?>/i, m => `${m}`); + if (out === html) throw new Error('page has no '); + return out; +} + +function build(webroot, recording, out) { + fs.rmSync(out, { recursive: true, force: true }); + fs.cpSync(webroot, out, { recursive: true }); + const files = listFiles(out); + + const { fixed: still, varying } = classify(recording.frames); + const fixed = { ...recording.once, ...still }; + const bad = Object.entries(fixed).filter(([, [status]]) => status === 429); + if (bad.length) throw new Error(`rate limited while recording: ${bad.map(([k]) => k).join(', ')}`); + + const dir = path.join(out, 'demo-replay'); + fs.mkdirSync(dir); + const series = {}; + for (const key of varying) { + series[key.slice('GET '.length)] = splitSeries(recording.frames, key).map(({ name, json }) => { + fs.writeFileSync(path.join(dir, name), json); + return name; + }); + } + const index = JSON.stringify({ + start: recording.start, + step: recording.step, + count: recording.frames.length, + chunk: CHUNK_FRAMES, + fixed, + series, + routes: fallbackRoutes({ fixed, writes: recording.writes }), + missing: MISSING, + }); + const indexName = `index.${hash(index)}.json`; + fs.writeFileSync(path.join(dir, indexName), index); + const script = fs + .readFileSync(path.join(__dirname, 'replay.js'), 'utf8') + .replace('__REPLAY_INDEX__', `/demo-replay/${indexName}`); + const scriptName = `replay.${hash(script)}.js`; + fs.writeFileSync(path.join(dir, scriptName), script); + + for (const f of files.filter(p => p.endsWith('.html'))) { + const file = path.join(out, f); + fs.writeFileSync(file, injectScript(fs.readFileSync(file, 'utf8'), `/demo-replay/${scriptName}`)); + } + + const replayHeaders = { ...recording.headers.files['/index.html'] }; + replayHeaders['cache-control'] = 'public, max-age=31536000, immutable'; + const replayFiles = Object.fromEntries(listFiles(dir, '/demo-replay').map(p => [p, replayHeaders])); + fs.writeFileSync( + path.join(out, '_headers'), + headerRules({ ...recording.headers.files, ...replayFiles }, recording.headers.routes), + ); + fs.writeFileSync(path.join(out, '_redirects'), redirectRules(Object.keys(recording.headers.routes))); + return { pages: files.filter(p => p.endsWith('.html')).length, fixed: Object.keys(fixed).length, varying }; +} + +module.exports = { classify, splitSeries, fallbackRoutes, headerRules, redirectRules, injectScript, build }; + +if (require.main === module) { + const [webroot, recordingFile, out] = process.argv.slice(2); + if (!out) { + console.error('usage: node scripts/demo/build.js '); + process.exit(2); + } + const result = build(webroot, JSON.parse(fs.readFileSync(recordingFile, 'utf8')), out); + console.log(`${result.pages} pages, ${result.fixed} fixed answers, replaying ${result.varying.join(', ')}`); +} diff --git a/scripts/demo/record.js b/scripts/demo/record.js new file mode 100644 index 00000000..65f8e17e --- /dev/null +++ b/scripts/demo/record.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { listFiles, writeRoutes } = require('./source'); + +const STEP_MS = 5000; +/* A valid address, so routes that call out reach the demo-mode refusal, not input validation. */ +const PROBE_BODY = JSON.stringify({ url: 'https://example.com', name: 'demo' }); + +/** @param {string} origin @param {string} target */ +async function headersOf(origin, target) { + const r = await fetch(origin + target, { method: 'HEAD', redirect: 'manual' }); + return r.status === 200 ? Object.fromEntries(r.headers) : null; +} + +/** @param {string} origin @param {string} method @param {string} target @param {string|null} body */ +async function exchange(origin, method, target, body = null) { + const r = await fetch(origin + target, { + method, + headers: method === 'GET' ? {} : { Origin: origin, 'Content-Type': 'application/json' }, + body: method === 'GET' ? undefined : (body ?? '{}'), + }); + return [r.status, await r.text()]; +} + +async function record({ origin, tour, webroot, minutes, apiSource }) { + /* Do not remove. The write probes below change the config of a container that + is not in demo mode. */ + const [, local] = await exchange(origin, 'GET', '/api/icons/local'); + if (JSON.parse(local).demo !== true) throw new Error(`${origin} is not in demo mode`); + const files = {}; + for (const f of listFiles(webroot)) { + files[f] = await headersOf(origin, f); + if (!files[f]) throw new Error(`${f} is in the web root but the image does not serve it`); + } + const routes = {}; + for (const f of Object.keys(files).filter(p => p.endsWith('/index.html'))) { + const dir = f.slice(0, -'index.html'.length); + for (const r of dir === '/' ? ['/'] : [dir, dir.slice(0, -1)]) { + const h = await headersOf(origin, r); + if (h) routes[r] = h; + } + } + + const gets = [...new Set(tour.filter(e => e.method === 'GET').map(e => e.path))].sort(); + const once = {}; + const writes = []; + const known = writeRoutes(apiSource); + for (const e of tour.filter(x => x.method !== 'GET')) { + const route = known.find(w => w.method === e.method && new RegExp(w.pattern).test(e.path.split('?')[0])); + if (!route) throw new Error(`${e.method} ${e.path} matches no route in the API source`); + once[`${e.method} ${e.path}`] = [e.status, e.body]; + if (!writes.some(w => w.route === route.route && w.method === e.method)) { + writes.push({ method: e.method, route: route.route, answer: [e.status, e.body] }); + } + } + const widgetId = gets.map(g => /^\/api\/widget-config\/([^/?]+)/.exec(g)?.[1]).find(Boolean) || 'demo'; + for (const w of known) { + if (writes.some(x => x.route === w.route && x.method === w.method)) continue; + writes.push({ + method: w.method, + route: w.route, + answer: await exchange(origin, w.method, w.route.replace(/:[a-z]+/gi, widgetId), PROBE_BODY), + }); + } + + const count = Math.round((minutes * 60_000) / STEP_MS); + const start = Math.ceil(Date.now() / STEP_MS) * STEP_MS; + const frames = []; + for (let i = 0; i < count; i++) { + await new Promise(r => setTimeout(r, Math.max(0, start + i * STEP_MS - Date.now()))); + const frame = {}; + await Promise.all( + gets.map(async g => { + frame[`GET ${g}`] = await exchange(origin, 'GET', g); + }), + ); + frames.push(frame); + } + return { start, step: STEP_MS, frames, once, writes, headers: { files, routes } }; +} + +module.exports = { record }; + +if (require.main === module) { + const [origin, tourFile, webroot, out, minutes = '10'] = process.argv.slice(2); + if (!out) { + console.error('usage: node scripts/demo/record.js [minutes]'); + process.exit(2); + } + record({ + origin, + tour: JSON.parse(fs.readFileSync(tourFile, 'utf8')).exchanges, + webroot, + minutes: Number(minutes), + apiSource: path.join(__dirname, '..', '..', 'api', 'src'), + }).then(rec => { + fs.writeFileSync(out, JSON.stringify(rec)); + console.log( + `${rec.frames.length} frames of ${Object.keys(rec.frames[0]).length} addresses, ${rec.writes.length} write routes`, + ); + }); +} diff --git a/scripts/demo/replay.js b/scripts/demo/replay.js new file mode 100644 index 00000000..5951d00c --- /dev/null +++ b/scripts/demo/replay.js @@ -0,0 +1,61 @@ +(() => { + const INDEX = '__REPLAY_INDEX__'; + const realFetch = window.fetch.bind(window); + const loaded = new Map(); + /* A 404 means a newer release replaced this page's recording. Only a reload + fetches the new one. One reload a minute, so a missing file cannot loop. */ + const RELOADED_AT = 'demo-replay-reloaded-at'; + const reloadOnce = () => { + try { + if (Date.now() - (Number(sessionStorage.getItem(RELOADED_AT)) || 0) < 60_000) return; + sessionStorage.setItem(RELOADED_AT, String(Date.now())); + } catch { + return; + } + location.reload(); + }; + /* A failed load is dropped, so the next request retries it. Kept, it fails every + later request until the page reloads. */ + const load = path => { + if (!loaded.has(path)) { + const p = realFetch(path).then(r => { + if (r.status === 404) reloadOnce(); + if (!r.ok) throw new Error(`${path} answered ${r.status}`); + return r.json(); + }); + p.catch(() => loaded.delete(path)); + loaded.set(path, p); + } + return loaded.get(path); + }; + const ISO_DATE = /"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z)"/g; + + async function answer(d, method, url) { + const path = url.pathname + url.search; + const fixed = d.fixed[`${method} ${path}`]; + if (fixed) return fixed; + const series = method === 'GET' && d.series[path]; + if (series) { + const span = d.step * d.count; + const i = Math.floor(((((Date.now() - d.start) % span) + span) % span) / d.step); + const [status, body] = (await load(`/demo-replay/${series[Math.floor(i / d.chunk)]}`))[i % d.chunk]; + /* Keep this shift. Without it, widgets show every reading as stale. */ + const shift = Date.now() - (d.start + i * d.step); + return [status, body.replace(ISO_DATE, (_, iso) => `"${new Date(Date.parse(iso) + shift).toISOString()}"`)]; + } + for (const [pattern, recorded] of d.routes) { + if (pattern.method === method && new RegExp(pattern.path).test(url.pathname)) return recorded; + } + console.warn('[demo-replay] not recorded:', method, path); + return d.missing; + } + + window.fetch = async (input, init) => { + const req = input instanceof Request ? input : null; + const url = new URL(req ? req.url : String(input), location.href); + if (url.origin !== location.origin || !url.pathname.startsWith('/api/')) return realFetch(input, init); + const method = (init?.method || req?.method || 'GET').toUpperCase(); + const [status, body] = await answer(await load(INDEX), method, url); + return new Response(body, { status, headers: { 'content-type': 'application/json' } }); + }; +})(); diff --git a/scripts/demo/source.js b/scripts/demo/source.js new file mode 100644 index 00000000..54ff8f67 --- /dev/null +++ b/scripts/demo/source.js @@ -0,0 +1,30 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +/** @param {string} dir @returns {string[]} paths below dir, each starting with / */ +function listFiles(dir, prefix = '') { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => { + const rel = `${prefix}/${e.name}`; + return e.isDirectory() ? listFiles(path.join(dir, e.name), rel) : [rel]; + }); +} + +/** @param {string} route @returns {string} */ +function routePattern(route) { + const escaped = route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/:[a-z]+/gi, '[^/]+'); + return `^${escaped}$`; +} + +/** @param {string} apiSrc @returns {Array<{ method: string, route: string, pattern: string }>} */ +function writeRoutes(apiSrc) { + const out = []; + for (const f of listFiles(apiSrc).filter(p => p.endsWith('.js'))) { + const src = fs.readFileSync(path.join(apiSrc, f), 'utf8'); + for (const m of src.matchAll(/\bon\(\s*'(POST|PUT|PATCH|DELETE)',\s*'(\/api\/[^']+)'/g)) { + out.push({ method: m[1], route: m[2], pattern: routePattern(m[2]) }); + } + } + return out.sort((a, b) => a.route.localeCompare(b.route)); +} + +module.exports = { listFiles, routePattern, writeRoutes }; diff --git a/scripts/demo/tour.js b/scripts/demo/tour.js new file mode 100644 index 00000000..fefdbd1a --- /dev/null +++ b/scripts/demo/tour.js @@ -0,0 +1,242 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const { chromium } = require('@playwright/test'); + +const VIEWS = [ + { name: 'desktop', viewport: { width: 1440, height: 900 } }, + { name: 'desktop-light', viewport: { width: 1440, height: 900 }, colorScheme: 'light' }, + { name: 'tablet', viewport: { width: 820, height: 1180 }, isMobile: true, hasTouch: true }, + { name: 'phone', viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }, + { name: 'narrow', viewport: { width: 320, height: 640 }, isMobile: true, hasTouch: true }, + { name: 'rtl', viewport: { width: 1440, height: 900 }, locale: 'fa-IR' }, +]; +const CHECKED_HEADERS = [ + 'content-security-policy', + 'x-frame-options', + 'cache-control', + 'x-content-type-options', + 'referrer-policy', + 'permissions-policy', +]; + +async function run(origin) { + const browser = await chromium.launch(); + const exchanges = []; + const problems = []; + const errors = new Set(); + const outcomes = new Set(); + let changed = false; + + const open = async view => { + const ctx = await browser.newContext(view); + ctx.on('response', async r => { + const u = new URL(r.url()); + if (u.origin !== origin) return; + if (u.pathname.startsWith('/api/')) { + const body = await r.text().catch(() => ''); + exchanges.push({ method: r.request().method(), path: u.pathname + u.search, status: r.status(), body }); + } else if (r.status() >= 400) { + problems.push(`${view.name}: ${r.status()} ${u.pathname}`); + } + }); + const page = await ctx.newPage(); + page.setDefaultTimeout(5000); + page.setDefaultNavigationTimeout(30_000); + page.on('pageerror', e => errors.add(`page error: ${e.message}`)); + page.on('console', m => { + if (m.text().startsWith('[demo-replay]')) problems.push(`${view.name}: ${m.text()}`); + else if (m.type() === 'error') errors.add(`console: ${m.text()}`); + }); + return { ctx, page }; + }; + + for (const view of VIEWS) { + const { ctx, page } = await open(view); + await page.goto(`${origin}/`, { waitUntil: 'networkidle' }); + const before = await widgetText(page); + await page.waitForTimeout(25_000); + if ((await widgetText(page)) !== before) changed = true; + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + if (overflow > 0) problems.push(`${view.name}: the dashboard scrolls sideways by ${overflow}px`); + await page.keyboard.press('ArrowRight'); + await page.waitForTimeout(1500); + await page.keyboard.press('ArrowLeft'); + await page.mouse.click(5, 5); + await page.keyboard.type('a'); + await page.waitForTimeout(800); + await page.keyboard.press('Escape'); + await ctx.close(); + } + + for (const view of [VIEWS[0], VIEWS[3]]) { + const { ctx, page } = await open(view); + await page.goto(`${origin}/admin`, { waitUntil: 'networkidle' }); + for (const sec of ['general', 'appearance', 'about', 'dashboard']) { + await page + .locator(`[data-sec="${sec}"]:visible`) + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(500); + } + const edits = page.locator('#sec-dashboard button:text-is("Edit")'); + const n = await edits.count(); + for (let i = 0; i < n; i++) { + await edits + .nth(i) + .click() + .catch(() => {}); + await page.waitForTimeout(700); + await page + .locator('button:has-text("Save"):visible') + .first() + .click() + .catch(() => {}); + outcomes.add(`${view.name}: save: ${await toastText(page)}`); + await page + .locator('button:has-text("Back"):visible') + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(300); + } + await page + .locator('#btn-add') + .click() + .catch(() => {}); + await page + .locator('#ip-in') + .fill('jellyfin') + .catch(() => {}); + await page.waitForTimeout(1500); + await page + .locator('#hc-en') + .evaluate(e => /** @type {HTMLElement} */ (e).click()) + .catch(() => {}); + await page + .locator('#hc-type-ping') + .evaluate(e => /** @type {HTMLElement} */ (e).click()) + .catch(() => {}); + await page.evaluate(() => { + const input = /** @type {HTMLInputElement|null} */ (document.getElementById('hc-ping')); + if (input) input.value = 'https://example.com'; + }); + await page + .locator('#hc-ping-test') + .evaluate(e => /** @type {HTMLElement} */ (e).click()) + .catch(() => {}); + await page.waitForTimeout(2500); + outcomes.add( + `${view.name}: test: ${await page + .locator('#hc-ping-status') + .innerText() + .catch(() => '')}`, + ); + await page.goto(`${origin}/admin`, { waitUntil: 'networkidle' }); + await page + .locator('[data-sec="general"]:visible') + .first() + .click() + .catch(() => {}); + const download = page.waitForEvent('download', { timeout: 5000 }).then( + d => `download ${d.suggestedFilename()}`, + () => 'no download', + ); + await page + .locator('#btn-exp') + .click({ force: true }) + .catch(() => {}); + outcomes.add(`${view.name}: export: ${await download}`); + await ctx.close(); + } + + await browser.close(); + return { exchanges, problems, errors: [...errors].sort(), outcomes: [...outcomes].sort(), changed }; +} + +/** @param {import('@playwright/test').Page} page */ +async function toastText(page) { + await page + .waitForFunction(() => document.getElementById('toast')?.textContent?.trim(), null, { timeout: 3000 }) + .catch(() => {}); + const text = await page.evaluate(() => document.getElementById('toast')?.textContent?.trim() || ''); + await page.evaluate(() => { + const toast = document.getElementById('toast'); + if (toast) toast.textContent = ''; + }); + return text; +} + +/** @param {import('@playwright/test').Page} page */ +async function widgetText(page) { + const texts = []; + for (const frame of page.frames().filter(x => x !== page.mainFrame())) { + texts.push(await frame.evaluate(() => document.body?.innerText || '').catch(() => '')); + } + return texts.join('\n'); +} + +async function checkHeaders(origin, recording) { + const problems = []; + const targets = { ...recording.headers.files, ...recording.headers.routes }; + for (const [target, want] of Object.entries(targets)) { + const r = await fetch(origin + target, { method: 'HEAD', redirect: 'manual' }); + if (r.status !== 200) { + problems.push(`${target}: ${r.status}`); + continue; + } + for (const n of CHECKED_HEADERS) { + const got = r.headers.get(n) ?? undefined; + if (got !== want[n]) problems.push(`${target}: ${n} is ${got}, the image sends ${want[n]}`); + } + } + return problems; +} + +async function main() { + const [mode, origin, tourFile, recordingFile] = process.argv.slice(2); + if (mode === 'discover' && tourFile) { + const result = await run(origin); + if (!result.changed) throw new Error('no widget changed in 25 seconds on the container'); + if (result.outcomes.some(o => o.endsWith(': ') || o.endsWith('no download'))) { + throw new Error(`the container showed no result for an action:\n${result.outcomes.join('\n')}`); + } + fs.writeFileSync( + tourFile, + JSON.stringify({ exchanges: result.exchanges, errors: result.errors, outcomes: result.outcomes }), + ); + console.log(`${result.exchanges.length} API exchanges, ${result.errors.length} console errors on the container`); + console.log(result.outcomes.join('\n')); + return; + } + if (mode === 'verify' && recordingFile) { + const container = JSON.parse(fs.readFileSync(tourFile, 'utf8')); + const baseline = new Set(container.errors); + const result = await run(origin); + const problems = [...result.problems]; + for (const e of result.errors) if (!baseline.has(e)) problems.push(e); + const seen = new Set(result.outcomes); + for (const o of container.outcomes) + if (!seen.has(o)) problems.push(`the container showed "${o}", the static demo did not`); + for (const o of result.outcomes) + if (!container.outcomes.includes(o)) problems.push(`the static demo showed "${o}"`); + if (!result.changed) problems.push('no widget changed in 25 seconds'); + problems.push(...(await checkHeaders(origin, JSON.parse(fs.readFileSync(recordingFile, 'utf8'))))); + if (problems.length) { + console.error(problems.join('\n')); + process.exit(1); + } + console.log('The static demo matches the container.'); + return; + } + console.error('usage: tour.js discover | verify '); + process.exit(2); +} + +main().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/demo/wrangler.jsonc b/scripts/demo/wrangler.jsonc new file mode 100644 index 00000000..688219c6 --- /dev/null +++ b/scripts/demo/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "https://unpkg.com/wrangler@4.151.3/config-schema.json", + "name": "stackyard-demo", + "compatibility_date": "2026-10-01", + "workers_dev": true, + "preview_urls": true, + "assets": { + "directory": "./site", + "html_handling": "none", + "not_found_handling": "none" + } +} diff --git a/test/repo/demo-build.test.js b/test/repo/demo-build.test.js new file mode 100644 index 00000000..9d72a638 --- /dev/null +++ b/test/repo/demo-build.test.js @@ -0,0 +1,161 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { + classify, + splitSeries, + fallbackRoutes, + headerRules, + redirectRules, + injectScript, +} = require('../../scripts/demo/build'); +const { writeRoutes, routePattern } = require('../../scripts/demo/source'); + +const ok = body => [200, JSON.stringify(body)]; + +test('an answer that never changed is fixed, one that changed is replayed', () => { + const frames = [ + { 'GET /api/config': ok({ a: 1 }), 'GET /api/widget-data/w': ok({ cpu: 10 }) }, + { 'GET /api/config': ok({ a: 1 }), 'GET /api/widget-data/w': ok({ cpu: 12 }) }, + ]; + const { fixed, varying } = classify(frames); + assert.deepEqual(Object.keys(fixed), ['GET /api/config']); + assert.deepEqual(varying, ['GET /api/widget-data/w']); +}); + +test('a series is cut into chunks named by their content', () => { + const frames = Array.from({ length: 5 }, (_, i) => ({ k: ok({ i }) })); + const chunks = splitSeries(frames, 'k', 2); + assert.deepEqual( + chunks.map(c => JSON.parse(c.json).length), + [2, 2, 1], + ); + const changed = splitSeries([{ k: ok({ i: 9 }) }, ...frames.slice(1)], 'k', 2); + assert.notEqual(changed[0].name, chunks[0].name, 'new content must get a new name, or browsers keep the old chunk'); + assert.equal(changed[1].name, chunks[1].name); +}); + +const ping = { + method: 'POST', + route: '/api/ping', + answer: ok({ + ok: false, + error: 'Outbound requests are disabled in demo mode', + kind: 'blocked', + code: 'blocked.demo', + }), +}; + +test('a query the recording never saw falls back only when every recorded query failed', () => { + const routes = fallbackRoutes({ + fixed: { + 'GET /api/icons/search?q=plex': [502, '{"error":"x"}'], + 'GET /api/icons/variants?ref=a': ok({ variants: ['a'] }), + 'GET /api/icons/variants?ref=b': [404, '{}'], + }, + writes: [ping], + }); + const gets = routes.filter(([r]) => r.method === 'GET').map(([r]) => r.path); + assert.deepEqual(gets, [routePattern('/api/icons/search')]); +}); + +test('an options lookup answers with the outbound refusal the image gives', () => { + const routes = fallbackRoutes({ + fixed: {}, + writes: [ping, { method: 'POST', route: '/api/widget-options/:id', answer: [400, '{}'] }], + }); + const [, [status, body]] = routes.find(([r]) => r.path === routePattern('/api/widget-options/:id')); + assert.equal(status, 502); + assert.equal(JSON.parse(body).error, 'Outbound requests are disabled in demo mode'); + assert.throws(() => + fallbackRoutes({ fixed: {}, writes: [{ method: 'POST', route: '/api/widget-options/:id', answer: [400, '{}'] }] }), + ); + const invalidPing = { ...ping, answer: [400, JSON.stringify({ ok: false, error: 'url required', kind: 'invalid' })] }; + assert.throws( + () => + fallbackRoutes({ + fixed: {}, + writes: [invalidPing, { method: 'POST', route: '/api/widget-options/:id', answer: [400, '{}'] }], + }), + /demo-mode refusal/, + 'an input error recorded for ping must not stand in for the demo refusal', + ); +}); + +const BASE = { + 'content-security-policy': 'default', + 'x-frame-options': 'SAMEORIGIN', + 'x-content-type-options': 'nosniff', +}; +const WIDGET = { 'content-security-policy': 'widget', 'x-content-type-options': 'nosniff' }; + +test('header rules start from the main page and group folders that differ', () => { + const text = headerRules({ + '/index.html': BASE, + '/js/a.js': BASE, + '/js/b.js': BASE, + '/widgets/w/index.html': WIDGET, + '/widgets/w/data.js': WIDGET, + }); + assert.match(text, /^\/\*\n {2}Content-Security-Policy: default\n/); + const widgets = text.split('\n\n').find(r => r.startsWith('/widgets/*')); + assert.ok(widgets, text); + assert.match(widgets, /! Content-Security-Policy\n {2}Content-Security-Policy: widget/); + assert.match(widgets, /! X-Frame-Options\n/); + assert.doesNotMatch(widgets, /X-Frame-Options: /); + assert.doesNotMatch(text, /\/js\//, 'a folder with the base headers needs no rule'); +}); + +test('a folder with mixed headers gets one rule per differing file', () => { + const text = headerRules({ + '/index.html': BASE, + '/mix/x.js': BASE, + '/mix/y.js': { ...BASE, 'cache-control': 'no-store' }, + }); + assert.match(text, /^\/mix\/y\.js\n {2}Cache-Control: no-store$/m); + assert.doesNotMatch(text, /^\/mix\/\*/m); +}); + +test('a route that needs other headers than its folder rule stops the build', () => { + const files = { '/index.html': BASE, '/admin/index.html': WIDGET }; + assert.doesNotThrow(() => headerRules(files, { '/admin/': WIDGET, '/admin': WIDGET })); + assert.throws(() => headerRules(files, { '/admin/': BASE }), /needs other headers/); +}); + +test('the base is the main page even when most files are widgets', () => { + const files = { '/index.html': BASE, '/w/a.js': WIDGET, '/w/b.js': WIDGET, '/w/c.js': WIDGET }; + assert.match(headerRules(files), /^\/\*\n {2}Content-Security-Policy: default\n/); + assert.throws(() => headerRules({ '/w/a.js': WIDGET }), /no \/index.html/); +}); + +test('more rules than Cloudflare reads stops the build', () => { + const files = { '/index.html': BASE }; + for (let i = 0; i < 120; i++) files[`/f${i}.js`] = { ...BASE, 'cache-control': `max-age=${i}` }; + assert.throws(() => headerRules(files), /Cloudflare allows 100/); +}); + +test('each route the image answered serves its folder page', () => { + assert.equal( + redirectRules(['/admin/', '/', '/admin']), + '/ /index.html 200\n/admin /admin/index.html 200\n/admin/ /admin/index.html 200\n', + ); +}); + +test('the replay script loads before anything else in the page', () => { + const html = + ''; + const out = injectScript(html, '/demo-replay/replay.1.js'); + assert.ok(out.indexOf('replay.1.js') < out.indexOf('/js/a.js')); + assert.ok(out.startsWith('')); + assert.throws(() => injectScript('

no head

', '/x.js')); +}); + +test('every write route of the API is found, with its parameters', () => { + const routes = writeRoutes(path.join(__dirname, '..', '..', 'api', 'src')).map(r => `${r.method} ${r.route}`); + assert.ok(routes.includes('POST /api/config')); + assert.ok(routes.includes('POST /api/widget-options/:id')); + assert.ok(routes.every(r => /^(POST|PUT|PATCH|DELETE) \/api\//.test(r))); + assert.match('/api/widget-options/w-stats', new RegExp(routePattern('/api/widget-options/:id'))); + assert.doesNotMatch('/api/widget-options/a/b', new RegExp(routePattern('/api/widget-options/:id'))); +}); diff --git a/test/repo/demo-replay.test.js b/test/repo/demo-replay.test.js new file mode 100644 index 00000000..b7e30571 --- /dev/null +++ b/test/repo/demo-replay.test.js @@ -0,0 +1,158 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const SOURCE = fs.readFileSync(path.join(__dirname, '..', '..', 'scripts', 'demo', 'replay.js'), 'utf8'); +const ORIGIN = 'https://demo.example'; +const STEP = 5000; +const START = 1_700_000_000_000; + +const index = { + start: START, + step: STEP, + count: 4, + chunk: 2, + fixed: { + 'GET /api/config': [200, '{"items":[]}'], + 'POST /api/config': [403, '{"code":"blocked.read-only"}'], + }, + series: { '/api/widget-data/w?endpoint=system': ['c0.json', 'c1.json'] }, + routes: [[{ method: 'GET', path: '^/api/icons/search$' }, [502, '{"error":"search"}']]], + missing: [503, '{"error":"missing"}'], +}; +const chunks = { + 'c0.json': [ + [200, '{"cpu":0,"ts":"2023-11-14T22:13:20.000Z"}'], + [200, '{"cpu":1}'], + ], + 'c1.json': [ + [200, '{"cpu":2}'], + [200, '{"cpu":3}'], + ], +}; + +function page(now, intercept = () => null, storage = new Map()) { + const warnings = []; + const passed = []; + const reloads = []; + const window = { + fetch: async input => { + const url = String(input); + const stubbed = intercept(url); + if (stubbed) return stubbed; + if (url === '/demo-replay/index.test.json') return new Response(JSON.stringify(index)); + const name = url.replace('/demo-replay/', ''); + if (chunks[name]) return new Response(JSON.stringify(chunks[name])); + passed.push(url); + return new Response('static'); + }, + }; + const ctx = { + window, + location: { href: `${ORIGIN}/`, origin: ORIGIN, reload: () => reloads.push(now) }, + sessionStorage: { + getItem: k => (storage.has(k) ? storage.get(k) : null), + setItem: (k, v) => storage.set(k, String(v)), + }, + Number, + String, + console: { warn: (...a) => warnings.push(a.join(' ')) }, + Date: class extends Date { + static now() { + return now; + } + }, + URL, + Request, + Response, + Map, + JSON, + Math, + RegExp, + }; + vm.runInNewContext(SOURCE.replace('__REPLAY_INDEX__', '/demo-replay/index.test.json'), ctx); + const get = async (url, init) => { + const r = await window.fetch(url, init); + return { status: r.status, body: await r.text() }; + }; + return { get, warnings, passed, reloads }; +} + +test('a fixed answer replays as recorded', async () => { + const { get } = page(START); + assert.deepEqual(await get('/api/config'), { status: 200, body: '{"items":[]}' }); +}); + +test('a write gets its own recorded answer, by method', async () => { + const { get } = page(START); + assert.equal((await get('/api/config', { method: 'POST', body: '{}' })).status, 403); + assert.equal((await get(new Request(`${ORIGIN}/api/config`, { method: 'POST', body: '{}' }))).status, 403); +}); + +test('a series follows the clock and loops', async () => { + const at = async ms => JSON.parse((await page(START + ms).get('/api/widget-data/w?endpoint=system')).body).cpu; + assert.equal(await at(0), 0); + assert.equal(await at(STEP), 1); + assert.equal(await at(3 * STEP + 4999), 3); + assert.equal(await at(4 * STEP), 0, 'the recording must loop'); + assert.equal(await at(-STEP), 3, 'a clock behind the recording start must still land on a frame'); +}); + +test('a recorded timestamp keeps the age it had when recorded', async () => { + const later = START + 4 * STEP * 10 + 1234; + const body = JSON.parse((await page(later).get('/api/widget-data/w?endpoint=system')).body); + assert.equal(Date.parse(body.ts), later); +}); + +test('an unrecorded query uses its route fallback', async () => { + assert.deepEqual(await page(START).get('/api/icons/search?q=anything'), { status: 502, body: '{"error":"search"}' }); +}); + +test('an unrecorded request is reported, not hidden', async () => { + const p = page(START); + assert.equal((await p.get('/api/nothing')).status, 503); + assert.match(p.warnings[0], /^\[demo-replay\] not recorded: GET \/api\/nothing$/); +}); + +test('a failed load is retried on the next request', async () => { + let fail = 2; + const p = page(START, url => { + if (fail > 0) { + fail--; + return new Response('', { status: url.endsWith('index.test.json') ? 503 : 404 }); + } + return null; + }); + await assert.rejects(p.get('/api/config')); + await assert.rejects(p.get('/api/widget-data/w?endpoint=system')); + assert.equal(JSON.parse((await p.get('/api/widget-data/w?endpoint=system')).body).cpu, 0); +}); + +test('a chunk the new release no longer serves reloads the page, once a minute at most', async () => { + const gone = url => (url.endsWith('.json') && !url.includes('index') ? new Response('', { status: 404 }) : null); + const storage = new Map(); + const first = page(START, gone, storage); + await assert.rejects(first.get('/api/widget-data/w?endpoint=system')); + await assert.rejects(first.get('/api/widget-data/w?endpoint=system')); + assert.equal(first.reloads.length, 1, 'repeated 404s must not reload again'); + const soon = page(START + 30_000, gone, storage); + await assert.rejects(soon.get('/api/widget-data/w?endpoint=system')); + assert.equal(soon.reloads.length, 0, 'a reload within a minute would loop'); + const later = page(START + 61_000, gone, storage); + await assert.rejects(later.get('/api/widget-data/w?endpoint=system')); + assert.equal(later.reloads.length, 1); + const offline = page(START, url => + url.endsWith('.json') && !url.includes('index') ? new Response('', { status: 503 }) : null, + ); + await assert.rejects(offline.get('/api/widget-data/w?endpoint=system')); + assert.equal(offline.reloads.length, 0, 'only a missing file means a new release'); +}); + +test('files and other sites go to the network', async () => { + const p = page(START); + await p.get('/js/a.js'); + await p.get('https://cdn.example/api/x'); + assert.deepEqual(p.passed, ['/js/a.js', 'https://cdn.example/api/x']); +});