weavesignal/tests/screens/drive.mjs
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

228 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//
// Drives the Schedule tab on the screens loopback stack (tests/screens/run-screens.sh) from
// the keyboard, checks each step against the control plane, and captures the week, the
// episode, series and show editors, the card editor and the log editor at 1,440 × 900 and
// 2,560 × 1,440 in both themes, as M2's driver did for the desk (tests/m2/drive.mjs).
//
// PLAYWRIGHT_CORE=/path/to/node_modules/playwright-core OUT=/some/dir node tests/screens/drive.mjs
//
// Playwright is not a dependency of the repository. Screenshots go to OUT, never the repository.
import { createRequire } from 'node:module';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.PLAYWRIGHT_CORE ?? 'playwright-core');
const STUDIO = process.env.STUDIO ?? 'http://127.0.0.1:26420/';
const CONTROL = process.env.CONTROL ?? 'http://127.0.0.1:26310';
const TOKEN = process.env.ADMIN_TOKEN ?? 'screens-admin-test-only';
const OUT = process.env.OUT ?? '/tmp/weavesignal-screens-shots';
const ZONE = process.env.STATION_ZONE ?? 'Europe/London';
mkdirSync(OUT, { recursive: true });
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
function check(name, ok, detail = '') {
results.push({ name, ok, detail });
log(ok ? 'PASS' : 'FAIL', name, detail);
}
const api = async (method, path, body) => {
const res = await fetch(`${CONTROL}${path}`, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => ({})) };
};
const get = async (path) => (await api('GET', path)).body;
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM });
async function open(width, height, theme = 'dark') {
const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1, timezoneId: ZONE });
await ctx.addInitScript((t) => {
try {
localStorage.setItem('weavesignal.theme', t);
} catch {}
}, theme);
const page = await ctx.newPage();
page.on('pageerror', (e) => check('no page errors', false, e.message));
await page.goto(STUDIO);
await page.getByText('Control plane connected').waitFor({ timeout: 15000 });
await page.keyboard.press('Control+5');
await page.waitForSelector('.schedule .ec', { timeout: 15000 });
await sleep(1200);
return { ctx, page };
}
async function shot(page, name) {
await page.mouse.move(2, 2);
const path = join(OUT, `${name}.png`);
await page.screenshot({ path });
log('shot', path);
}
// ---- the keyboard-driven run at 1,440 × 900, dark ----
const { ctx, page } = await open(1440, 900, 'dark');
// ⌃5 opened the tab; the week is this one.
const range = await page.locator('.toolbar .range').textContent();
check('⌃5 opens the Schedule tab on this week', /\d{4}/.test(range ?? ''), range ?? '');
await shot(page, 'schedule-week-1440-dark');
// ← and → move a week; T comes back.
await page.keyboard.press('ArrowLeft');
await sleep(600);
const prev = await page.locator('.toolbar .range').textContent();
check('← moves to the week before', prev !== range, prev ?? '');
const dst = await page.locator('.toolbar .tz').textContent();
check('the clocks-back Sunday is named when the week has one', /25 h|clocks|24 h|Europe/.test(dst ?? ''), dst ?? '');
await shot(page, 'schedule-week-before-1440-dark');
await page.keyboard.press('KeyT');
await sleep(600);
check('T is today', (await page.locator('.toolbar .range').textContent()) === range);
// Tab to the Saturday Night Kettle block and open it with ⏎.
const snk = page.locator('.ev', { hasText: 'Saturday Night' }).first();
await snk.focus();
await page.keyboard.press('Enter');
await sleep(800);
const head = await page.locator('.editor h2').first().textContent();
check('⏎ on a block opens its episode editor', /Episode \d+/.test(head ?? ''), head ?? '');
const inherited = await page.locator('.field.inh').count();
const set = await page.locator('.field.set').count();
check('fields carry inherited and set-here markers', inherited > 0 && set > 0, `${inherited} inherited, ${set} set`);
await shot(page, 'schedule-episode-1440-dark');
// Set the hosts here, save, and the control plane holds the episode's own hosts.
const crumbs = (await page.locator('.editor .crumbs').first().textContent()) ?? '';
const epNo = Number(/Episode (\d+)/.exec(head ?? '')?.[1]);
const hostsField = page.locator('.field', { has: page.locator('.lab', { hasText: 'Hosts' }) }).first();
await hostsField.getByRole('button', { name: 'Set here' }).click();
await page.getByLabel('Hosts, separated by commas').fill('Shona Campbell, Catriona MacKenzie-Buchanan, Rory Bruce');
await page.getByLabel('Hosts, separated by commas').press('Tab');
await page.getByRole('button', { name: 'Save episode' }).click();
await sleep(1500);
const sv = await get('/api/v1/shows/saturday-night/series/3');
const ep = sv.episodes?.find((e) => e.episode.number === epNo);
check('Save episode sends the hosts set here', Array.isArray(ep?.episode.hosts) && ep.episode.hosts.length === 3, JSON.stringify(ep?.resolved.hosts));
check('and the episode no longer inherits hosts', !ep?.resolved.inherited.includes('hosts'), JSON.stringify(ep?.resolved.inherited));
// Reset to series: inheriting again.
await page.locator('.field', { has: page.locator('.lab', { hasText: 'Hosts' }) }).first().getByRole('button', { name: 'Reset to series' }).click();
await page.getByRole('button', { name: 'Save episode' }).click();
await sleep(1500);
const sv2 = await get('/api/v1/shows/saturday-night/series/3');
const ep2 = sv2.episodes?.find((e) => e.episode.number === epNo);
check('Reset to series inherits again', ep2?.episode.hosts === null && ep2?.resolved.inherited.includes('hosts'), JSON.stringify(ep2?.resolved.inherited));
// The card editor: Poster, then Slate, the preview and the alt text; a refused name names the field.
await page.getByRole('button', { name: 'Card…' }).click();
await page.locator('.card-preview img').waitFor({ timeout: 20000 });
const alt = await page.locator('.card-preview .alt').textContent();
check('the card editor draws the preview with its alt text', /Saturday Night Kettle/.test(alt ?? ''), (alt ?? '').slice(0, 80));
await shot(page, 'schedule-card-poster-1440-dark');
await page.locator('.card-editor, [data-panel="calendar.card-editor"]').getByRole('button', { name: 'Slate', exact: true }).click();
await sleep(2500);
check('Slate is drawn as a trial until kept', (await page.getByRole('button', { name: 'Keep for the show' }).count()) === 1);
await shot(page, 'schedule-card-slate-1440-dark');
const refused = await api('POST', '/api/v1/cards/words', {
layout: 'poster',
colour_slot: 3,
show: { name: 'The Not Quite Late Enough Saturday Night Shipping Forecast Appreciation Society '.repeat(3), slug: 'x' },
hosts: [],
guests: [],
});
check('a name that does not fit is refused with the field named (422)', refused.status === 422 && /show name/.test(refused.body.error ?? ''), refused.body.error ?? '');
await page.getByRole('button', { name: 'Back to the editor' }).click();
// Series level: draft the season.
await page.getByRole('tab', { name: /Series/ }).click();
await sleep(1200);
await shot(page, 'schedule-series-1440-dark');
const before = (await get('/api/v1/shows/saturday-night/series/3')).episodes.length;
const draftBtn = page.getByRole('button', { name: /Draft the season/ });
if (await draftBtn.isEnabled()) {
await draftBtn.click();
await sleep(4000);
const after = (await get('/api/v1/shows/saturday-night/series/3')).episodes.length;
check('Draft the season drafts the rest from the rule', after === 10 && after > before, `${before} → ${after}`);
} else {
check('Draft the season has nothing left to draft', before === 10, `${before}`);
}
await page.getByRole('tab', { name: 'Show' }).click();
await sleep(1200);
await shot(page, 'schedule-show-1440-dark');
// The log editor: an automation block, the rule checks, a refused edit.
await page.getByRole('tab', { name: 'Episode' }).click();
// The first automation block still ahead: a past hour has no planned items to show.
await page.locator('.ev.auto:not(.past)').first().focus();
await page.keyboard.press('Enter');
await page.locator('[data-panel="calendar.log-editor"]').waitFor({ timeout: 10000 });
await sleep(1500);
const items = await page.locator('.log .item').count();
check('an automation block opens the generated hours', items > 0, `${items} items`);
const breaches = await page.locator('.log .item .breaches .state').count();
check('rule checks are shown in words', breaches >= 0, `${breaches} breaches shown`);
await shot(page, 'schedule-log-1440-dark');
// Replace a track with one by the same artist a minute away: the artist separation refuses, force takes it.
const logItems = (await get(`/api/v1/schedule/log?from_ms=${Date.now()}&to_ms=${Date.now() + 6 * 3600_000}`)).items.filter((i) => i.kind === 'track' && i.status === 'planned');
if (logItems.length >= 2) {
const a = logItems[0];
const b = logItems.find((i) => i.id !== a.id && i.track !== a.track);
const same = (await get('/api/v1/library/tracks')).tracks.find((t) => t.artist === a.artist && t.id !== a.track);
if (b && same) {
const r1 = await api('PUT', `/api/v1/schedule/log/${b.id}`, { track: same.id });
check('the log editor refuses a break of the station’s separation and offers force', r1.status === 409 || r1.status === 200, `${r1.status} ${r1.body.error ?? r1.body.note ?? ''}`);
if (r1.status === 409 && /force/.test(r1.body.error ?? '')) {
const r2 = await api('PUT', `/api/v1/schedule/log/${b.id}`, { track: same.id, force: true });
check('force accepts the station’s own break', r2.status === 200, `${r2.status} ${r2.body.note ?? ''}`);
}
}
}
// On air now: the episode freezes and its fields read as aired.
await snk.focus();
await page.keyboard.press('Enter');
await sleep(800);
await page.getByRole('button', { name: 'On air now' }).click();
await sleep(3000);
check('On air now freezes the episode', (await page.locator('.field.frozen').count()) > 0 && (await page.locator('.frozen-note').count()) === 1);
await shot(page, 'schedule-aired-1440-dark');
// The name plates (ADR-0070): every panel named, and none over a control.
const audit = await ctx.newPage();
await audit.goto(`${STUDIO}?audit=1`);
await audit.getByText('Control plane connected').waitFor({ timeout: 15000 });
await audit.keyboard.press('Control+5');
await audit.waitForSelector('.schedule .ec', { timeout: 15000 });
await sleep(1500);
const plates = await audit.evaluate(() => document.getElementById('pn-audit')?.textContent ?? '');
check('the panel name plates sit clear of controls', /plates \d+/.test(plates) && !/OVER/.test(plates), plates.split('\n')[0]);
await audit.screenshot({ path: join(OUT, 'schedule-1440-dark-plates.png') });
await ctx.close();
// ---- the other sizes and the light theme, for the comparison against the design ----
for (const [w, h] of [
[1440, 900],
[2560, 1440],
]) {
for (const theme of ['dark', 'light']) {
if (w === 1440 && theme === 'dark') continue;
const { ctx: c, page: p } = await open(w, h, theme);
await shot(p, `schedule-week-${w}-${theme}`);
await p.locator('.ev', { hasText: 'Saturday Night' }).first().click();
await sleep(1000);
await shot(p, `schedule-episode-${w}-${theme}`);
await c.close();
}
}
await browser.close();
const failed = results.filter((r) => !r.ok);
writeFileSync(join(OUT, 'results.json'), JSON.stringify(results, null, 2));
log(`${results.length - failed.length} of ${results.length} checks passed`);
process.exit(failed.length ? 1 : 0);