Files
pig/scripts/motion-overflow-check.mjs
karti 376ef3d597 Answer 404 for an id that cannot name a row, rather than 500
Measured against a running server: five of the eleven Motion `:id` routes
answered `500 {"error":"Internal error"}` for an id like `nope`, and the
other six only answered 400 because their body schema happened to be
checked first — a valid body would have reached the same cast.

Nothing was wrong with the not-found handling. That branch was never
reached: every id column is a `uuid`, so Postgres refuses the parameter
with `22P02` several layers below it, and the error is not a
MutationError so it leaves as a 500.

404 rather than 400, because a 400 for a malformed id and a 404 for a
well-formed one tells anyone probing which of their guesses are the right
shape — and this feature already routes "somebody else's private draft"
through the same 404 so that no answer distinguishes the reasons a row is
not yours to see.

Also adds the AGENTS.md §5 393px check for the five Motion routes, which
scripts/screenshots.mjs does not photograph. All five measure zero
horizontal overflow at 393 and 1440, light and dark.

The same 500 is reachable on /api/accounts/:id and /api/contracts/:id,
which predates this branch and is left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:41:28 -07:00

115 lines
4.6 KiB
JavaScript

/*
* The 393px check from AGENTS.md §5, run over the Motion routes.
*
* node scripts/motion-overflow-check.mjs # PIG_WEB_URL=http://127.0.0.1:8975
*
* `scripts/screenshots.mjs` already asserts zero horizontal overflow, but only
* on the ten pages it photographs, and Motion added five it does not know
* about. The rule it enforces has been fixed at individual call sites three
* separate times, so a new page group that nobody measures is exactly how it
* comes back.
*
* Checked per route, in light and dark, at 393 and 1440:
* - scrollWidth === clientWidth on the document (the sideways-scroll failure)
* - no element wider than the viewport (which one, if so — the message has to
* name the offender or nobody can act on it)
* - nothing thrown to the console, and no failed request
*/
import { chromium } from 'playwright';
const BASE = process.env.PIG_WEB_URL ?? 'http://127.0.0.1:8975';
const ROUTES = [
{ slug: 'motion', path: '/motion' },
{ slug: 'library', path: '/motion/library' },
{ slug: 'engagements', path: '/motion/engagements' },
];
const VIEWPORTS = [
{ name: 'mobile', width: 393, height: 852 },
{ name: 'desktop', width: 1440, height: 900 },
];
const browser = await chromium.launch({ channel: 'chrome' });
let failures = 0;
/** The detail pages need a real id, so they are discovered rather than typed. */
const api = await fetch(`${BASE}/api/motion/templates`).then((r) => r.json());
const template = api.templates?.[0];
if (template) ROUTES.push({ slug: 'template', path: `/motion/library/${template.id}` });
const engagements = await fetch(`${BASE}/api/motion/engagements`).then((r) => r.json());
const engagement = engagements.engagements?.[0];
if (engagement) ROUTES.push({ slug: 'engagement', path: `/motion/engagements/${engagement.id}` });
for (const theme of ['light', 'dark']) {
for (const vp of VIEWPORTS) {
const ctx = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
colorScheme: theme,
isMobile: vp.name === 'mobile',
hasTouch: vp.name === 'mobile',
});
// The sidebar and the dock are per-device states in localStorage; left to
// whatever a human last set, the measurement is not reproducible.
await ctx.addInitScript(`
localStorage.setItem('pig.sidebar', 'expanded');
localStorage.setItem('pig.piggy.dock', 'closed');
`);
for (const route of ROUTES) {
const page = await ctx.newPage();
const problems = [];
page.on('console', (m) => {
if (m.type() !== 'error') return;
if (m.text().includes('/api/piggy/') || m.text().includes('Failed to load resource')) return;
problems.push(`console: ${m.text()}`);
});
page.on('pageerror', (e) => problems.push(`threw: ${e.message}`));
page.on('response', (r) => {
// Piggy is a separate container and is normally down on a dev host, so
// its 503 is a fact about the environment rather than about the page.
// Every other status belongs to the route under test.
if (r.status() >= 400 && !r.url().includes('/api/piggy/')) {
problems.push(`${r.status()} ${r.url().replace(BASE, '')}`);
}
});
await page.goto(`${BASE}${route.path}`, { waitUntil: 'networkidle' });
// The pages are lazy chunks behind a Suspense boundary; a measurement
// taken before the fallback resolves measures the spinner.
await page.waitForTimeout(1200);
const measured = await page.evaluate(() => {
const doc = document.documentElement;
const overflow = doc.scrollWidth - doc.clientWidth;
const wide = [...document.querySelectorAll('*')]
.filter((el) => el.getBoundingClientRect().width > doc.clientWidth + 1)
.slice(0, 3)
.map((el) => `${el.tagName.toLowerCase()}.${(el.className?.baseVal ?? el.className ?? '')
.toString()
.split(' ')
.slice(0, 4)
.join('.')}`);
return { overflow, wide, text: document.body.innerText.slice(0, 120) };
});
const label = `${route.slug} ${vp.name} ${theme}`;
if (measured.overflow !== 0) {
failures++;
console.log(`FAIL ${label}: overflows by ${measured.overflow}px — ${measured.wide.join(', ')}`);
} else if (problems.length) {
failures++;
console.log(`FAIL ${label}: ${[...new Set(problems)].slice(0, 4).join(' | ')}`);
} else {
console.log(`ok ${label}`);
}
await page.close();
}
await ctx.close();
}
}
await browser.close();
console.log(failures === 0 ? '\nevery Motion route: no overflow, no error' : `\n${failures} failed`);
process.exit(failures === 0 ? 0 : 1);