Skip to content

Commit 4951dc0

Browse files
Merge issue-198-199-integrated-main5: Reconcile planning browser contracts and seed SAM frontend parity (#198,#199)
2 parents 3e0fb19 + 69a0887 commit 4951dc0

15 files changed

Lines changed: 880 additions & 88 deletions

backend/e2e/calendar-seams.spec.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ test("production portal calendar covers month/week, layers, overlay, dismiss/rea
172172
path: path.join(shots, "production-month-populated.png"),
173173
});
174174
await surface.locator("select[data-view]").selectOption("week");
175-
await expect(surface.locator(".calendar-grid section.calendar-day")).toHaveCount(7);
175+
await expect(surface.locator(".calendar-weeks > .calendar-week")).toHaveCount(1);
176+
await expect(surface.locator(".calendar-weeks > .calendar-week > section.calendar-day")).toHaveCount(7);
176177
await surface.screenshot({ path: path.join(shots, "production-week.png") });
177178
await surface.locator('[data-layer="school"]').uncheck();
178179
await expect(surface.getByText("Summer holidays")).toHaveCount(0);
@@ -258,7 +259,8 @@ test("production calendar renders cross-year ISO weeks and Berlin DST boundary d
258259
await surface.locator("select[data-view]").selectOption("week");
259260
await expect(surface.getByText("Cross-year activity").first()).toBeVisible();
260261
await expect(surface.locator('time[datetime="2027-01-01"]')).toBeVisible();
261-
await expect(surface.locator(".calendar-grid section.calendar-day")).toHaveCount(7);
262+
await expect(surface.locator(".calendar-weeks > .calendar-week")).toHaveCount(1);
263+
await expect(surface.locator(".calendar-weeks > .calendar-week > section.calendar-day")).toHaveCount(7);
262264
await expect(surface.locator(".iso-week")).toContainText(["ISO 53"]);
263265
await surface.screenshot({
264266
path: path.join(shots, "production-year-boundary.png"),

backend/e2e/frontend-module-characterization.spec.js

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { createDocsCacheRoot } = require("./helpers/docs-content-root");
77
const { resolveTestServerCommand } = require("./helpers/tsx-launcher");
88

99
const ROOT = path.resolve(__dirname, "..", "..");
10-
const SCREENSHOT_DIR = path.join(ROOT, ".tmp", "screenshots", "issue-190");
10+
const SCREENSHOT_DIR = path.join(ROOT, ".tmp", "screenshots", "issue-198");
1111
const FIXTURE_DOC_PATH = "content/overview/reference/schedule.md";
1212
const FIXTURE_DOC_TITLE = "Synthetic Characterization Schedule";
1313
const FIXTURE_DOC_SUMMARY = "Synthetic reference used only by browser characterization tests.";
@@ -335,11 +335,29 @@ test.describe("pre-refactor frontend module characterization", () => {
335335
await expect(page.locator("body")).toBeVisible();
336336
await expectNoHorizontalOverflow(page, route);
337337
if (route === "/users") {
338-
const tableOverflow = await page.locator(".ops-users-table-wrap").evaluate((wrapper) => ({
339-
clientWidth: wrapper.clientWidth,
340-
scrollWidth: wrapper.scrollWidth,
341-
}));
342-
expect(tableOverflow.scrollWidth).toBeLessThanOrEqual(tableOverflow.clientWidth);
338+
const userRow = page.locator(".ops-user-row").first();
339+
await expect(userRow).toBeVisible();
340+
const stackedRow = await userRow.evaluate((row) => {
341+
const rect = row.getBoundingClientRect();
342+
return {
343+
display: getComputedStyle(row).display,
344+
height: rect.height,
345+
viewportWidth: document.documentElement.clientWidth,
346+
width: rect.width,
347+
};
348+
});
349+
expect(stackedRow.display).toBe("grid");
350+
expect(stackedRow.height).toBeGreaterThan(48);
351+
expect(stackedRow.width).toBeLessThanOrEqual(stackedRow.viewportWidth);
352+
await expect(userRow.locator(".ops-user-name")).toBeVisible();
353+
await expect(userRow.locator(".ops-user-email")).toBeVisible();
354+
await expect(userRow.locator(".ops-user-role")).toBeVisible();
355+
await expect(userRow.locator(".ops-user-created")).toBeVisible();
356+
await expect(userRow.locator(".ops-user-actions .quiet-button").first()).toBeVisible();
357+
await page.screenshot({
358+
path: path.join(SCREENSHOT_DIR, "users-mobile-stacked.png"),
359+
fullPage: true,
360+
});
343361
}
344362
}
345363
expect(errors).toEqual([]);

backend/e2e/global-setup.js

Lines changed: 145 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const path = require('path');
44
const fs = require('fs');
55
const { resolveTestServerCommand } = require('./helpers/tsx-launcher');
66

7-
const TEST_SERVER_PORT = 3001;
7+
const { TEST_SERVER_PORT } = require('./test-server-port');
88
const READY_TIMEOUT_MS = 30000;
99
const POLL_INTERVAL_MS = 300;
1010
const READY_PATH = '/api/health';
@@ -26,14 +26,14 @@ const GRACE_USER = {
2626
createdAt: '2026-01-01T00:00:00.000Z',
2727
};
2828

29-
function writeDefaultAuthState() {
29+
function writeDefaultAuthState(port = TEST_SERVER_PORT) {
3030
fs.writeFileSync(
3131
AUTH_STATE_PATH,
3232
JSON.stringify({
3333
cookies: [],
3434
origins: [
3535
{
36-
origin: `http://localhost:${TEST_SERVER_PORT}`,
36+
origin: `http://localhost:${port}`,
3737
localStorage: [
3838
{ name: 'dataops_token', value: 'e2e-bypass-token' },
3939
{ name: 'dataops_user', value: JSON.stringify(GRACE_USER) },
@@ -44,13 +44,13 @@ function writeDefaultAuthState() {
4444
);
4545
}
4646

47-
function buildTestServerEnvironment(parentEnvironment = process.env) {
47+
function buildTestServerEnvironment(parentEnvironment = process.env, port = TEST_SERVER_PORT) {
4848
return {
4949
...parentEnvironment,
5050
NODE_ENV: 'test',
5151
IS_LOCAL: 'true',
5252
SKIP_AUTH: 'true',
53-
PORT: String(TEST_SERVER_PORT),
53+
PORT: String(port),
5454
FRONTEND_ROOT: path.resolve(__dirname, '..', '..', 'frontend'),
5555
// Server-owned actor for template-admin tests only. Other route permission
5656
// tests retain their existing explicit actor/no-actor behavior.
@@ -146,7 +146,139 @@ function waitForServer(port, timeoutMs, options = {}) {
146146
});
147147
}
148148

149-
async function globalSetup() {
149+
/**
150+
* A health response proves that *a* server owns the fixed port, not that the
151+
* child just spawned owns it. Require that child's successful listen log before
152+
* probing health so a foreign listener cannot satisfy bootstrap.
153+
*/
154+
function waitForOwnedServer(child, port, timeoutMs) {
155+
const readyMarker = `Test server listening at http://localhost:${port}`;
156+
157+
return new Promise((resolve, reject) => {
158+
let settled = false;
159+
let retryTimer;
160+
let activeRequest;
161+
const deadline = Date.now() + timeoutMs;
162+
163+
function cleanup() {
164+
clearTimeout(retryTimer);
165+
if (activeRequest) activeRequest.destroy();
166+
child.stdout?.off('data', onOutput);
167+
child.stderr?.off('data', onErrorOutput);
168+
child.off('error', onError);
169+
child.off('exit', onExit);
170+
}
171+
172+
function settle(operation) {
173+
if (settled) return;
174+
settled = true;
175+
cleanup();
176+
operation();
177+
}
178+
179+
function fail(message) {
180+
settle(() => reject(new Error(message)));
181+
}
182+
183+
function succeeded() {
184+
settle(resolve);
185+
}
186+
187+
function childStatus() {
188+
return `code=${child.exitCode ?? 'none'}, signal=${child.signalCode ?? 'none'}`;
189+
}
190+
191+
function scheduleProbe(delayMs) {
192+
if (settled) return;
193+
if (child.exitCode !== null || child.signalCode !== null) {
194+
fail(`Test server child exited before readiness (${childStatus()}).`);
195+
return;
196+
}
197+
198+
const waitMs = Math.min(delayMs, Math.max(0, deadline - Date.now()));
199+
if (waitMs <= 0 && retryTimer === undefined) {
200+
fail(
201+
`The spawned test server announced ${readyMarker}, but /api/health `
202+
+ `did not return HTTP 200 within ${timeoutMs}ms.`
203+
);
204+
return;
205+
}
206+
retryTimer = setTimeout(() => {
207+
retryTimer = undefined;
208+
probe();
209+
}, waitMs);
210+
}
211+
212+
function probe() {
213+
if (settled) return;
214+
let completed = false;
215+
const request = http.get(`http://localhost:${port}${READY_PATH}`, (response) => {
216+
if (completed || settled) {
217+
response.resume();
218+
return;
219+
}
220+
completed = true;
221+
activeRequest = undefined;
222+
response.resume();
223+
if (response.statusCode === 200) {
224+
succeeded();
225+
return;
226+
}
227+
scheduleProbe(POLL_INTERVAL_MS);
228+
});
229+
request.on('error', () => {
230+
if (completed || settled) return;
231+
completed = true;
232+
activeRequest = undefined;
233+
scheduleProbe(POLL_INTERVAL_MS);
234+
});
235+
request.setTimeout(Math.min(1000, Math.max(1, deadline - Date.now())), () => {
236+
if (completed || settled) return;
237+
completed = true;
238+
activeRequest = undefined;
239+
request.destroy();
240+
scheduleProbe(POLL_INTERVAL_MS);
241+
});
242+
activeRequest = request;
243+
}
244+
245+
function onOutput(chunk) {
246+
if (settled) return;
247+
if (String(chunk).includes(readyMarker)) probe();
248+
}
249+
250+
function onErrorOutput() {}
251+
252+
function onError(error) {
253+
fail(`Test server child failed before readiness: ${error.message}`);
254+
}
255+
256+
function onExit() {
257+
fail(`Test server child exited before readiness (${childStatus()}).`);
258+
}
259+
260+
if (child.exitCode !== null || child.signalCode !== null) {
261+
fail(`Test server child had already exited (${childStatus()}).`);
262+
return;
263+
}
264+
265+
child.stdout?.setEncoding('utf8');
266+
child.stderr?.setEncoding('utf8');
267+
child.stdout?.on('data', onOutput);
268+
child.stderr?.on('data', onErrorOutput);
269+
child.once('error', onError);
270+
child.once('exit', onExit);
271+
retryTimer = setTimeout(() => {
272+
retryTimer = undefined;
273+
fail(
274+
`Test server child did not announce ${readyMarker} within ${timeoutMs}ms; `
275+
+ 'refusing to use another listener.'
276+
);
277+
}, timeoutMs);
278+
});
279+
}
280+
281+
async function globalSetup({ port = TEST_SERVER_PORT } = {}) {
150282
// Playwright specs also launch isolated test-server children. Keep every
151283
// process in this test-only tree on the same explicit dark rollout state.
152284
Object.assign(process.env, DARK_ROLLOUT_ENVIRONMENT);
@@ -156,7 +288,7 @@ async function globalSetup() {
156288
const child = spawn(
157289
...resolveTestServerCommand(),
158290
{
159-
env: buildTestServerEnvironment(),
291+
env: buildTestServerEnvironment(undefined, port),
160292
stdio: ['ignore', 'pipe', 'pipe'],
161293
detached: true,
162294
}
@@ -178,18 +310,20 @@ async function globalSetup() {
178310
// Store the child process so teardown can kill it
179311
globalThis.__testServerProcess = child;
180312

181-
// Wait for the server to be ready before returning control to Playwright
182-
await waitForServer(TEST_SERVER_PORT, READY_TIMEOUT_MS);
313+
// Wait for this spawned process to own the port before returning control to
314+
// Playwright. A foreign HTTP 200 must never satisfy global setup.
315+
await waitForOwnedServer(child, port, READY_TIMEOUT_MS);
183316

184-
console.log(`[global-setup] Test server is ready on port ${TEST_SERVER_PORT}`);
317+
console.log(`[global-setup] Test server is ready on port ${port}`);
185318

186319
// UI tests do not need a server-side session while SKIP_AUTH=true. Use a
187320
// deterministic localStorage session so auth/logout tests cannot invalidate
188321
// the shared browser storage state for unrelated UI tests.
189-
writeDefaultAuthState();
322+
writeDefaultAuthState(port);
190323
console.log('[global-setup] Auth state saved with test bypass token for Grace');
191324
}
192325

193326
module.exports = globalSetup;
194327
module.exports.buildTestServerEnvironment = buildTestServerEnvironment;
195328
module.exports.waitForServer = waitForServer;
329+
module.exports.waitForOwnedServer = waitForOwnedServer;

backend/e2e/global-teardown.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ module.exports = async function globalTeardown() {
66
return;
77
}
88

9+
if (child.exitCode !== null || child.signalCode !== null) {
10+
console.log('[global-teardown] Test server already stopped.');
11+
return;
12+
}
13+
914
await new Promise((resolve) => {
1015
let settled = false;
16+
let forceKillTimer;
1117

1218
function done() {
1319
if (settled) return;
@@ -29,7 +35,7 @@ module.exports = async function globalTeardown() {
2935
}
3036

3137
// Force-kill after 5 seconds if SIGTERM is not enough
32-
const forceKillTimer = setTimeout(() => {
38+
forceKillTimer = setTimeout(() => {
3339
if (!settled) {
3440
console.warn('[global-teardown] Force-killing test server process group after timeout.');
3541
try {

backend/e2e/planning-surfaces-design.spec.js

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const path = require("path");
77
const { createDocsCacheRoot } = require("./helpers/docs-content-root");
88
const { resolveTestServerCommand } = require("./helpers/tsx-launcher");
99

10-
const screenshots = path.resolve(__dirname, "..", "..", ".tmp", "screenshots", "planning-surfaces");
10+
const screenshots = path.resolve(__dirname, "..", "..", ".tmp", "screenshots", "issue-198");
1111
const PORT = 3197;
1212
const BASE = `http://localhost:${PORT}`;
1313
let server;
@@ -135,6 +135,7 @@ test("Newsletter is a readable, responsive planning queue in light and dark them
135135
await expect(surface.getByText("An open slot needs booking soon")).toBeVisible();
136136
await expect(surface.getByText("near-term-open-unbooked")).toHaveCount(0);
137137
await expectNoPageOverflow(page, 1440);
138+
await expect(surface).toHaveCSS("gap", "20px");
138139
await expectExactPalette(page, false);
139140
const accessibility = await new AxeBuilder({ page }).include(".newsletter-surface").analyze();
140141
expect(accessibility.violations.filter((violation) => ["critical", "serious"].includes(violation.impact))).toEqual([]);
@@ -151,9 +152,14 @@ test("Newsletter is a readable, responsive planning queue in light and dark them
151152
await page.setViewportSize({ width: 820, height: 900 });
152153
await expect(surface).toHaveCSS("gap", "16px");
153154
await expectNoPageOverflow(page, 820);
155+
await page.screenshot({ path: path.join(screenshots, "newsletter-820-dark.png"), animations: "disabled" });
154156
await page.setViewportSize({ width: 620, height: 844 });
155157
expect(await surface.locator(".planner-filter-fields").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
156158
await expectNoPageOverflow(page, 620);
159+
await page.setViewportSize({ width: 420, height: 844 });
160+
expect(await surface.locator(".planner-filter-fields").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
161+
await expectNoPageOverflow(page, 420);
162+
await page.screenshot({ path: path.join(screenshots, "newsletter-420-dark.png"), animations: "disabled" });
157163

158164
await page.setViewportSize({ width: 390, height: 844 });
159165
expect(await surface.locator(".planner-filter-fields").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
@@ -174,8 +180,10 @@ test("Calendar keeps planner hierarchy and becomes a one-column agenda on mobile
174180
await expect(surface.getByText("Community webinar").first()).toBeVisible();
175181
await expect(surface.getByText("Activity overlaps a school holiday")).toBeVisible();
176182
await expect(surface.getByText("school-holiday-overlap")).toHaveCount(0);
177-
await expect(surface.locator(".calendar-grid .calendar-day")).toHaveCount(42);
183+
await expect(surface.locator(".calendar-weeks > .calendar-week")).toHaveCount(6);
184+
await expect(surface.locator(".calendar-weeks > .calendar-week > section.calendar-day")).toHaveCount(42);
178185
await expectNoPageOverflow(page, 1440);
186+
await expect(surface).toHaveCSS("gap", "20px");
179187
await expectExactPalette(page, false);
180188
const accessibility = await new AxeBuilder({ page }).include(".calendar-surface").analyze();
181189
expect(accessibility.violations.filter((violation) => ["critical", "serious"].includes(violation.impact))).toEqual([]);
@@ -188,20 +196,45 @@ test("Calendar keeps planner hierarchy and becomes a one-column agenda on mobile
188196
await page.screenshot({ path: path.join(screenshots, "calendar-1440-dark.png"), animations: "disabled" });
189197

190198
await page.setViewportSize({ width: 820, height: 900 });
199+
await expect(surface).toHaveCSS("gap", "16px");
191200
expect(await surface.locator(".calendar-grid").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
192201
await expectNoPageOverflow(page, 820);
202+
await page.screenshot({ path: path.join(screenshots, "calendar-820-dark.png"), animations: "disabled" });
193203
await page.setViewportSize({ width: 620, height: 844 });
194204
expect(await surface.locator(".planner-filter-fields").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
195205
await expectNoPageOverflow(page, 620);
206+
await page.setViewportSize({ width: 420, height: 844 });
207+
expect(await surface.locator(".planner-filter-fields").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
208+
await expectNoPageOverflow(page, 420);
209+
await page.screenshot({ path: path.join(screenshots, "calendar-420-dark.png"), animations: "disabled" });
196210

197211
await page.setViewportSize({ width: 390, height: 844 });
198212
expect(await surface.locator(".planner-filter-fields").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
199213
await surface.locator("select[data-view]").selectOption("week");
200-
await expect(surface.locator(".calendar-grid .calendar-day")).toHaveCount(7);
214+
await expect(surface.locator(".calendar-weeks > .calendar-week")).toHaveCount(1);
215+
await expect(surface.locator(".calendar-weeks > .calendar-week > section.calendar-day")).toHaveCount(7);
216+
const swipeHint = surface.locator(".calendar-mobile-hint");
217+
await expect(swipeHint).toBeVisible();
218+
await expect(swipeHint).toContainText("Swipe each week horizontally");
219+
const weekScroll = await surface.locator(".calendar-week").first().evaluate((element) => ({
220+
clientWidth: element.clientWidth,
221+
overflowX: getComputedStyle(element).overflowX,
222+
scrollWidth: element.scrollWidth,
223+
}));
224+
expect(weekScroll.overflowX).toBe("auto");
225+
expect(weekScroll.scrollWidth).toBeGreaterThan(weekScroll.clientWidth);
201226
await page.evaluate(() => window.scrollTo(0, 0));
202227
await useTheme(page, false);
203228
await expectNoPageOverflow(page, 390);
204-
await page.screenshot({ path: path.join(screenshots, "calendar-390-light.png"), animations: "disabled" });
229+
await page.screenshot({
230+
path: path.join(screenshots, "calendar-390-light.png"),
231+
animations: "disabled",
232+
fullPage: true,
233+
});
205234
await useTheme(page, true);
206-
await page.screenshot({ path: path.join(screenshots, "calendar-390-dark.png"), animations: "disabled" });
235+
await page.screenshot({
236+
path: path.join(screenshots, "calendar-390-dark.png"),
237+
animations: "disabled",
238+
fullPage: true,
239+
});
207240
});

0 commit comments

Comments
 (0)