check.mjsannotatedcheck.mjssource229 lines · 10.0 KB · raw
1#!/usr/bin/env node
2// `buck2 run //:check-songs [-- [--speed N] [--url URL] [song …]]`: plays every song (or
3// the ones named, as `theme/song` or `song`) fast in a silent headless tab
4// on the dev server, and prints what Jev did with each: the path it took,
5// its warnings, late answers and fallbacks. Exits nonzero when a song fails
6// to evaluate or the page throws.
7//
8// What it needs: `buck2 run //:dev` running (the site on its port, the Jev
9// relay beside it), and the devshell. Playwright and its browsers come
10// from the flake (`.#playwright`, `.#playwright-browsers`), one nixpkgs for
11// both so they always match; nothing is installed from npm.
12//
13// Run from a worktree, songs hear the worktree's samples/, not those of the
14// checkout the dev server serves (tools/worktree-samples/).
15//
16// The tab is `?session=check-<pid>`, which never plays out loud (see the repo
17// CLAUDE.md), and each song's tempo is multiplied by --speed, since what
18// Jev decides and what warns depends on the patterns, not the speed. At
19// high speed the parts request, asked after the section's, can land after
20// its section starts; those show as late here and are not late at 1×.
21import { execFileSync } from 'node:child_process';
22import { readdirSync, readFileSync, statSync } from 'node:fs';
23import { join } from 'node:path';
24import { sectionCycles } from '../../website/src/jev/measured.mjs';
25import { routingNote, worktreeSamples } from '../worktree-samples/worktree-samples.mjs';
26
27process.chdir(execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim());
28
29// ── arguments ───────────────────────────────────────
30const args = process.argv.slice(2);
31let speed = 4;
32let url = null; // --url: another dev server (a worktree's, on its own ports)
33const wanted = [];
34for (let i = 0; i < args.length; i++) {
35  if (args[i] === '--speed') speed = Number(args[++i]);
36  else if (args[i] === '--url') url = args[++i].replace(/\/$/, '');
37  else wanted.push(args[i]);
38}
39if (!(speed > 0)) {
40  console.error('check-songs: --speed takes a positive number');
41  process.exit(2);
42}
43
44// ── the songs: directories only ─────────────────────
45const dirs = (d) => readdirSync(d).filter((x) => statSync(join(d, x)).isDirectory());
46const songs = dirs('songs').flatMap((theme) =>
47  dirs(join('songs', theme)).map((song) => ({ id: `${theme}/${song}`, file: join('songs', theme, song, 'song.js') })),
48);
49const chosen = wanted.length
50  ? wanted.map((w) => {
51      const s = songs.find((x) => x.id === w || x.id.endsWith(`/${w}`));
52      if (!s) {
53        console.error(`check-songs: no song "${w}"; songs are ${songs.map((x) => x.id).join(', ')}`);
54        process.exit(2);
55      }
56      return s;
57    })
58  : songs;
59
60// ── the dev server ──────────────────────────────────
61// The site's port is set once, in website/astro.config.mjs (tools/dev/CLAUDE.md).
62if (!url) {
63  const port = readFileSync('website/astro.config.mjs', 'utf8').match(/\n  server: \{[^}]*?\bport: (\d+)/)?.[1];
64  if (!port) {
65    console.error("check-songs: could not read the site's port from website/astro.config.mjs");
66    process.exit(2);
67  }
68  url = `http://localhost:${port}`;
69}
70const URL_ = `${url}/?session=check-${process.pid}`;
71try {
72  await fetch(`${url}/`);
73} catch {
74  console.error(`check-songs: nothing answers on ${url}; start the dev server first (buck2 run //:dev)`);
75  process.exit(2);
76}
77// From a worktree, songs hear its own samples, not the dev server's checkout's
78const worktree = await worktreeSamples({ url, root: process.cwd() });
79if (worktree) console.log(routingNote('check-songs', worktree));
80
81// ── playwright, from the flake ──────────────────────
82const [playwright, browsers] = execFileSync(
83  'nix',
84  ['build', '--no-link', '--print-out-paths', '.#playwright', '.#playwright-browsers'],
85  { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] },
86)
87  .trim()
88  .split('\n');
89process.env.PLAYWRIGHT_BROWSERS_PATH = browsers;
90process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1';
91const { chromium } = await import(`${playwright}/lib/node_modules/playwright/index.mjs`);
92
93// ── the tab ─────────────────────────────────────────
94const browser = await chromium.launch();
95const page = await browser.newPage();
96let logs = [];
97let pageErrors = [];
98page.on('pageerror', (e) => pageErrors.push(e.message));
99// Counted so a song can tell it was cut short: after a reload the tab just
100// goes quiet, which would otherwise read as the song having finished. A new
101// document, not framenavigated: the REPL rewrites the URL on every evaluate.
102let navigations = 0;
103page.on('domcontentloaded', () => navigations++);
104page.on('console', (m) => logs.push(m.text().replace(/%c/g, '').replace(/background-color.*$/, '').trim()));
105// The featured song plays on a tab's first click (client.mjs takeAutoplay);
106// here it would land after the song being checked and replace it.
107await page.addInitScript(() => sessionStorage.setItem('jevstrudel.autoplayed', '1'));
108await worktree?.route(page);
109
110async function open() {
111  await page.goto(URL_);
112  await page.waitForFunction(() => window.strudelMirror, null, { timeout: 60_000 });
113  // browsers keep audio off until a gesture
114  await page.mouse.click(10, 10);
115}
116
117// The dev server reloads the tab when a song, sample or site file changes
118// (astro.config.mjs's jevSongsReload); an evaluate in flight then loses its
119// page. That is not the song's fault: open the tab again and retry.
120const reloaded = (e) => /Execution context was destroyed|navigat/i.test(e.message);
121
122const JEV = /^\[jev\] cycles /;
123const LATE = /answered after the section began/;
124const FALLBACK = /playing the fallbacks|played the fallback/;
125const NOISE = /sampler|load sound|piano\.json|Welcome|\[jev\]/;
126
127async function play(song) {
128  const code = readFileSync(song.file, 'utf8');
129  const cps = code.match(/setcps\(([^)]*)\)/);
130  if (!cps) return { error: 'no setcps(…) to speed up' };
131  const fast = code.replace(cps[0], `setcps((${cps[1]})*${speed})`);
132  // seconds a section lasts at this speed, for how long a quiet Jev means done
133  const bpm = Number(cps[1].split('/')[0]) || 120;
134  const every = sectionCycles(code);
135  const sectionMs = ((every * 4 * 60) / bpm / speed) * 1000;
136  // long enough for the longest form the song allows (walk's max, 16 by
137  // default), plus the opening and a margin
138  const maxSections = Math.max(16, ...[...code.matchAll(/\bmax:\s*(\d+)/g)].map((m) => Number(m[1])));
139  const limitMs = (maxSections + 3) * sectionMs + 10_000;
140
141  logs = [];
142  pageErrors = [];
143  const tab = navigations;
144  const cutShort = () => {
145    if (navigations !== tab) throw new Error('the page navigated: reloaded under the song');
146  };
147  const evalError = await page.evaluate(async (c) => {
148    const ed = window.strudelMirror;
149    await ed.stop();
150    ed.setCode(c);
151    await ed.evaluate();
152    return ed.repl.state.evalError?.message ?? null;
153  }, fast);
154  if (evalError) return { error: evalError };
155
156  // Done when Jev has said nothing new for three sections, or a song
157  // without Jev has played long enough to show its warnings.
158  const start = Date.now();
159  let seen = 0;
160  let lastJev = Date.now();
161  const quiet = 3 * sectionMs + 2000;
162  let timedOut = true;
163  while (Date.now() - start < limitMs) {
164    await page.waitForTimeout(500);
165    cutShort();
166    const n = logs.filter((l) => JEV.test(l)).length;
167    if (n !== seen) [seen, lastJev] = [n, Date.now()];
168    else if (Date.now() - lastJev > quiet) {
169      timedOut = false;
170      break;
171    }
172  }
173  cutShort();
174  await page.evaluate(() => window.strudelMirror.stop());
175
176  // Jev's lines are "<cycles>: <what>": a decision ("riff 1.5 (full…), …"),
177  // or a problem named for its question ("section: not confident…").
178  const kinds = { path: [], late: [], fallbacks: [], problems: [] };
179  for (const l of logs.filter((x) => JEV.test(x)).map((x) => x.replace(JEV, ''))) {
180    const what = l.slice(l.indexOf(': ') + 2);
181    if (LATE.test(what)) kinds.late.push(l);
182    else if (FALLBACK.test(what)) kinds.fallbacks.push(l);
183    else if (/^\w+: /.test(what)) kinds.problems.push(l);
184    else kinds.path.push(l);
185  }
186  return {
187    seconds: Math.round((Date.now() - start) / 1000),
188    ...kinds,
189    warnings: [...new Set(logs.filter((l) => /warn|error|is not|unreachable/i.test(l) && !NOISE.test(l)))],
190    pageErrors: [...pageErrors, ...(timedOut ? [`cut short: still playing after ${Math.round(limitMs / 1000)} s`] : [])],
191    // the relay's Retry-After, as the page reports it
192    retryAfter: Math.max(0, ...logs.map((l) => Number(l.match(/asked again in (\d+) s/)?.[1] ?? 0))),
193  };
194}
195
196await open();
197let failed = 0;
198for (const song of chosen) {
199  let result;
200  for (let attempt = 1; ; attempt++) {
201    try {
202      result = await play(song);
203      break;
204    } catch (e) {
205      if (!reloaded(e) || attempt === 3) throw e;
206      console.log(`   (the page reloaded under ${song.id}; opening it again)`);
207      await open();
208    }
209  }
210
211  const bad = result.error || result.pageErrors?.length;
212  if (bad) failed++;
213  console.log(`== ${song.id}${result.error ? `  EVALUATION ERROR: ${result.error}` : `  (${result.seconds} s)`}`);
214  if (result.error) continue;
215  for (const l of result.path) console.log(`   ${l}`);
216  if (!result.path.length) console.log('   (no Jev decisions)');
217  for (const l of result.late) console.log(`   late: ${l}`);
218  for (const l of result.fallbacks) console.log(`   fallback: ${l}`);
219  for (const l of result.problems) console.log(`   ! ${l}`);
220  for (const w of result.warnings) console.log(`   ! ${w.slice(0, 400)}`);
221  for (const e of result.pageErrors) console.log(`   PAGE ERROR: ${e}`);
222  if (result.retryAfter) {
223    console.log(`   (rate limited: waiting ${result.retryAfter} s, as the relay asked)`);
224    await page.waitForTimeout(result.retryAfter * 1000 + 1000);
225  }
226}
227await browser.close();
228console.log(`${chosen.length} songs, ${failed} failed`);
229process.exit(failed ? 1 : 0);