Skip to content

Commit e8282df

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/staging-loop
# Conflicts: # docs/operations.md
2 parents 164cf59 + f511b29 commit e8282df

40 files changed

Lines changed: 328 additions & 913 deletions

‎.claude/skills/verify-debatelab/bin/verify.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ cmd_up() {
203203
wait_url "$mailer_url/api/v1/messages"
204204
wait_url "$APP_ORIGIN"
205205
wait_url "http://127.0.0.1:$FAKE_PORT/__requests"
206-
for fn in workspace-invites ai ai-stream demo-code-login admin announcements; do
206+
for fn in workspace-invites ai ai-stream demo-code-login admin announcements voice account; do
207207
wait_url "$api_url/functions/v1/$fn" 300 --request OPTIONS --header "Origin: $APP_ORIGIN"
208208
done
209209
if [ "$collab" = true ]; then

‎docs/operations.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ The staging browser suite disables trace, screenshot, and video capture. It crea
6666

6767
Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass.
6868

69-
`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`, and it first waits up to 20 minutes for a successful `staging-sync` status on that commit. Quality runs from pull requests also trigger it, and those show as skipped by design. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- <run-id>`, not this page. The run uploads no artifact, so the job log is the only evidence.
69+
`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`, and it first waits up to 20 minutes for a successful `staging-sync` status on that commit. Quality runs from pull requests also trigger it, and those show as skipped by design. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- <run-id>`, not this page. The run uploads no artifact, so the job log is the only evidence. On failure the log names the failed step and the URL path, for example `Staging step "add a source to the debate" failed at path /app/library`.
70+
71+
No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, because the `Quality` run it waits for never started. Once Actions came back, it failed on every `main` push through 2026-10-02. The cause was the spec, not staging: since the Current Debate change (#72), Library disables **Add Source** until a debate is current, and the spec never started one. The spec now starts a uniquely named debate and deletes it afterwards. The suite drives the real UI, so a change to a flow it covers can break it with no backend drift; run it locally before blaming staging.
7072

7173
## Staging sync
7274

@@ -198,7 +200,7 @@ Do not expose protected data in artifacts. Record the commit, environment, opera
198200
## Incident clues
199201

200202
- Every Actions job fails in 2 to 5 seconds with no logs: Actions minutes or billing, not the code. Verify locally.
201-
- Green local E2E plus staging failure: schema, functions, provider access, demo code, or CORS may differ.
203+
- Green local E2E plus staging failure: schema, functions, provider access, demo code, or CORS may differ. Run the staging suite locally first; if it fails there too on a UI step, the spec is stale, not staging.
202204
- New frontend errors against Supabase right after a merge: the matching migration or function may not be applied to production yet. Run `npm run backend:status` from the linked checkout.
203205
- Function returns unauthorized after deploy: check handler auth and environment propagation before changing gateway settings.
204206
- Browser works on one route only: check the `vercel.json` SPA rewrite and cache headers.

‎docs/testing.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,11 +296,15 @@ The suite:
296296
- verifies that the anon key belongs to the supplied staging URL;
297297
- authenticates through `demo-code-login`;
298298
- proves session reload;
299-
- writes and reads one uniquely named source;
300-
- makes one bounded AI request;
301-
- deletes only the source it created.
299+
- removes debates an interrupted earlier run left behind (only motions starting `E2E staging debate `);
300+
- starts one uniquely named debate from Library, because sources belong to a Current Debate;
301+
- writes one uniquely named source, then reads it back after clearing local state;
302+
- makes one bounded AI request in Ask;
303+
- deletes the source through the UI and the debate through the REST API with the test user's own session (the app cannot delete the Current Debate), which also removes the chat Ask saved to it.
302304

303-
Temporary auth state lives under `test-results/.auth/`. The staging projects disable traces, screenshots, and video because browser captures can retain authenticated data.
305+
Use port `18080`: the staging Edge Functions answer browser requests only from allowlisted origins (see the [staging contract](operations.md#staging-contract)). The CI demo code exists only in the GitHub `staging` environment, so a local run needs its own bounded staging code.
306+
307+
Temporary auth state lives under `test-results/.auth/`. The staging projects disable traces, screenshots, and video because browser captures can retain authenticated data. Actions time out after 30 seconds, well inside the 180-second test timeout, so cleanup still runs after a failure. Each failure logs the step name and the URL path, never the query or hash. Playwright still writes an ARIA snapshot to `test-results/**/error-context.md` on failure; the workflow does not upload it, and it can contain staging account text.
304308

305309
The GitHub staging workflow runs only after an eligible main-branch quality result or a main-branch manual dispatch, and only when `STAGING_E2E_ENABLED=true`. The protected environment must already contain current secrets, functions, schema, provider access, a dedicated test code, and a main-only deployment rule.
306310

‎e2e/animation-polish.spec.ts‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,6 @@ async function openFeatureRoute(page: Page, route: 'brainstorm' | 'factoids' | '
6565
await expect(page).toHaveURL(new RegExp(`/app/${route}$`));
6666
}
6767

68-
test('keeps route and feature-state motion short and stable', async ({ page }, testInfo) => {
69-
await prepareMotionFixture(page, 'no-preference');
70-
const options = await openGenerateOptions(page);
71-
72-
await expect(page.locator('[data-route-transition-ms]')).toHaveCount(0);
73-
const transitionMs = await options.evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration) * 1000);
74-
expect(transitionMs).toBeGreaterThan(0);
75-
await page.screenshot({ path: testInfo.outputPath('normal-empty-options.png'), fullPage: true });
76-
77-
await generateMockCase(page);
78-
await expect(page.locator('#generate-results')).toBeVisible();
79-
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
80-
await expect(page.locator('main.page-enter')).toHaveCount(0);
81-
await expect(page.getByText('Generate complete', { exact: true })).toBeHidden({ timeout: 10_000 });
82-
await page.screenshot({ path: testInfo.outputPath('normal-result.png'), fullPage: true });
83-
});
84-
8568
test('removes non-essential route and state movement for reduced motion', async ({ page }, testInfo) => {
8669
await prepareMotionFixture(page, 'reduce');
8770
const options = await openGenerateOptions(page);

‎e2e/home-navigation.spec.ts‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,20 +124,6 @@ test('header keeps the brand and controls inside a 320px viewport', async ({ pag
124124
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320);
125125
});
126126

127-
test('desktop header holds Home, Current debate, Library, and History with no tool links', async ({ page }) => {
128-
test.skip((page.viewportSize()?.width ?? 0) < 1024, 'Desktop-only header check');
129-
await page.goto('/app', { waitUntil: 'domcontentloaded' });
130-
131-
const navigation = page.getByRole('navigation', { name: 'Main navigation' });
132-
await expect(navigation.getByRole('link')).toHaveText(['Home', 'Library', 'History']);
133-
await expect(navigation.getByTestId('current-debate-desktop')).toHaveText('Start debate');
134-
for (const tool of ['Generate', 'Attack', 'Ask', 'Crossfire', 'Sparring', 'More']) {
135-
await expect(navigation.getByText(tool, { exact: true })).toHaveCount(0);
136-
}
137-
await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
138-
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(await page.evaluate(() => window.innerWidth));
139-
});
140-
141127
test('desktop header fits without internal or document overflow at supported widths', async ({ page }) => {
142128
for (const width of [1024, 1280, 1440, 1920]) {
143129
await page.setViewportSize({ width, height: 900 });

‎e2e/loading-performance.spec.ts‎

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -60,40 +60,3 @@ test('landing CTA enters the app without replacing the document', async ({ page
6060
expect(await page.evaluate(() => performance.getEntriesByType('navigation'))).toHaveLength(1);
6161
expect(await page.evaluate(() => document.documentElement.dataset.e2eDocument)).toBe('original');
6262
});
63-
64-
test('protected sibling navigation keeps AppLayout mounted while the page loads', async ({ page }) => {
65-
await setLocale(page, 'en');
66-
await stubSupabaseSession(page);
67-
await page.addInitScript(() => {
68-
window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({
69-
state: { hasCompletedOnboarding: true, isTourRunning: false },
70-
version: 0,
71-
}));
72-
});
73-
await page.goto('/app');
74-
const header = page.locator('header');
75-
await expect(header).toBeVisible();
76-
await header.evaluate((element) => { element.dataset.e2eLayout = 'original'; });
77-
78-
let releaseAttackModule: (() => void) | undefined;
79-
const attackModuleReleased = new Promise<void>((resolve) => { releaseAttackModule = resolve; });
80-
let attackModuleRequested = false;
81-
await page.route('**/src/pages/AttackPage.tsx*', async (route) => {
82-
attackModuleRequested = true;
83-
await attackModuleReleased;
84-
await route.continue();
85-
});
86-
87-
// Tools open from Home; the header no longer lists them.
88-
await page.locator('main [data-tour="mode-attack"]').click();
89-
await expect.poll(() => attackModuleRequested).toBe(true);
90-
await page.waitForTimeout(250);
91-
92-
await expect(header).toBeVisible();
93-
expect(await header.getAttribute('data-e2e-layout')).toBe('original');
94-
95-
releaseAttackModule?.();
96-
await expect(page).toHaveURL(/\/app\/attack$/);
97-
await expect(page.getByRole('heading', { name: /Attack/i }).first()).toBeVisible();
98-
expect(await header.getAttribute('data-e2e-layout')).toBe('original');
99-
});

‎e2e/navigation-performance.spec.ts‎

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ declare global {
55
interface Window {
66
__baselineAppLayout?: Element | null;
77
__baselineShell?: Element | null;
8-
__baselineClickAt?: number | null;
98
__genericLoaderObserver?: MutationObserver;
109
__genericLoaderSeen?: boolean;
1110
}
@@ -125,13 +124,6 @@ test('a delayed destination chunk exposes the route fallback without replacing t
125124
await page.evaluate(() => {
126125
window.__baselineAppLayout = document.querySelector('header')?.parentElement;
127126
window.__baselineShell = document.querySelector('header');
128-
window.__baselineClickAt = null;
129-
document.addEventListener('click', (event) => {
130-
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
131-
if (anchor && new URL(anchor.href, window.location.href).pathname === '/app/attack') {
132-
window.__baselineClickAt = performance.now();
133-
}
134-
}, true);
135127
});
136128
let destinationRequested = false;
137129
await page.route('**/src/pages/AttackPage.tsx*', async (route) => {
@@ -178,27 +170,6 @@ test('a delayed destination chunk exposes the route fallback without replacing t
178170
appLayout: document.querySelector('header')?.parentElement === window.__baselineAppLayout,
179171
header: document.querySelector('header') === window.__baselineShell,
180172
}))).toEqual({ appLayout: true, header: true });
181-
182-
const transitionMetrics = await page.evaluate(() => ({
183-
clickTime: window.__baselineClickAt ?? null,
184-
route: location.pathname,
185-
shellDomIdentitySurvived: document.querySelector('header') === window.__baselineShell,
186-
allRequestCount: performance.getEntriesByType('resource').length,
187-
measuredRequestCount: performance.getEntriesByType('resource')
188-
.filter((entry) => window.__baselineClickAt !== null && entry.startTime >= window.__baselineClickAt).length,
189-
sourceRequestCount: performance.getEntriesByType('resource')
190-
.filter((entry) => window.__baselineClickAt !== null && entry.startTime < window.__baselineClickAt).length,
191-
allLongTaskCount: performance.getEntriesByType('longtask').length,
192-
measuredLongTaskCount: performance.getEntriesByType('longtask')
193-
.filter((entry) => window.__baselineClickAt !== null && entry.startTime >= window.__baselineClickAt).length,
194-
}));
195-
expect(transitionMetrics.clickTime).not.toBeNull();
196-
expect(transitionMetrics.route).toBe('/app/attack');
197-
expect(transitionMetrics.shellDomIdentitySurvived).toBe(true);
198-
expect(transitionMetrics.sourceRequestCount).toBeGreaterThan(0);
199-
expect(transitionMetrics.measuredRequestCount).toBeGreaterThan(0);
200-
expect(transitionMetrics.measuredRequestCount).toBeLessThan(transitionMetrics.allRequestCount);
201-
expect(transitionMetrics.measuredLongTaskCount).toBeLessThanOrEqual(transitionMetrics.allLongTaskCount);
202173
});
203174

204175
test('a failed route chunk recovers after the offered reload', async ({ page }) => {
@@ -226,35 +197,6 @@ test('a failed route chunk recovers after the offered reload', async ({ page })
226197
await expect(page.locator('header')).toBeVisible();
227198
});
228199

229-
for (const [locale, openingLabel] of [
230-
['es', 'Abriendo Atacar'],
231-
['hu', 'Támadás megnyitása'],
232-
] as const) {
233-
test(`${locale} delayed route announces the localized destination`, async ({ page }) => {
234-
await setLocale(page, locale);
235-
await stubSupabaseSession(page);
236-
await skipOnboarding(page);
237-
await page.goto('/app', { waitUntil: 'domcontentloaded' });
238-
await expect(homeToolLink(page, 'attack')).toBeVisible();
239-
240-
let destinationRequested = false;
241-
await page.route('**/src/pages/AttackPage.tsx*', async (route) => {
242-
destinationRequested = true;
243-
await new Promise((resolve) => setTimeout(resolve, 1500));
244-
await route.continue();
245-
});
246-
247-
await homeToolLink(page, 'attack').click();
248-
249-
await expect.poll(() => destinationRequested).toBe(true);
250-
const status = page.getByRole('status', { name: openingLabel, exact: true });
251-
await expect(status).toHaveCount(1);
252-
await expect(status).toHaveText(openingLabel);
253-
await expect(status).not.toHaveText('Opening Attack');
254-
await expect(page.locator('[data-route-skeleton="tool-form"][aria-busy="true"]')).toBeVisible();
255-
});
256-
}
257-
258200
async function gotoHome(page: Page) {
259201
await setLocale(page, 'en');
260202
await stubSupabaseSession(page);

‎e2e/public-pages.spec.ts‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,3 @@ for (const locale of LOCALES) {
3939
}
4040
});
4141
}
42-
43-
test('landing logos use the sized display asset with intrinsic dimensions', async ({ page }) => {
44-
await setLocale(page, 'en');
45-
await page.goto('/');
46-
47-
const logos = page.locator('img[alt="DebateLab logo"]');
48-
await expect(logos).toHaveCount(2);
49-
expect(await logos.evaluateAll((images) => images.map((image) => {
50-
const logo = image as HTMLImageElement;
51-
return {
52-
path: new URL(logo.currentSrc).pathname,
53-
width: logo.getAttribute('width'),
54-
height: logo.getAttribute('height'),
55-
};
56-
}))).toEqual([
57-
{ path: '/favicon-72.png', width: '36', height: '36' },
58-
{ path: '/favicon-72.png', width: '32', height: '32' },
59-
]);
60-
});

‎e2e/results-overflow.spec.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { expect, test } from '@playwright/test';
2+
import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth';
3+
4+
// Models quote raw URLs in evidence. On 2026-10-02 a live run quoted this
5+
// 135-character URL and the Generate results column grew past the viewport:
6+
// a 1fr grid track grows to the min-content width of an unbreakable word.
7+
const longUrl = 'https://admin.farefreelondon.org/wp-content/uploads/2026/04/20240320_GCC_Free_Public_Transport_Study_Summary_Report_v2.0.pdf';
8+
const motion = 'This House would make public transport free';
9+
10+
const contention = (index: number) => ({
11+
id: String(index + 1),
12+
claim: `Contention ${index + 1}: fares block the riders who need transit most`,
13+
reasoning: 'Fares are a regressive barrier concentrated on low-income riders.',
14+
evidence: `The 2026 policy brief (${longUrl}) found fares covered 8 percent of operating costs.`,
15+
needsSource: false,
16+
impact: 'More people reach work, school, and healthcare.',
17+
});
18+
19+
test('a long URL in generated evidence does not widen the page', async ({ page }) => {
20+
await setLocale(page, 'en');
21+
await stubSupabaseSession(page);
22+
await seedCurrentDebate(page, motion);
23+
await page.route('**/functions/v1/ai', async (route) => {
24+
await route.fulfill({
25+
status: 200,
26+
contentType: 'application/json',
27+
body: JSON.stringify({
28+
output: {
29+
caseStructure: {
30+
thesis: 'Free transit is a net public good.',
31+
framework: 'Net societal benefit, weighed on access and cost.',
32+
keyDefinitions: ['Public transport: publicly funded scheduled transit.'],
33+
burdenOfProof: 'We must show the policy produces a net gain in practice.',
34+
},
35+
contentions: [0, 1, 2].map(contention),
36+
predictedOpponentCase: {
37+
likelyArguments: ['The lost farebox revenue has to come from somewhere.'],
38+
strategicWeaknesses: ['Assumes fare revenue is the only funding model.'],
39+
keyClashes: ['Who actually bears the cost.'],
40+
},
41+
counters: [{ targetArgument: 'Funding gap', counterpoint: 'Fare collection has overhead.', rebuttal: 'Net cost is small.' }],
42+
metadata: { side: 'pro', format: 'custom', generatedAt: new Date().toISOString() },
43+
},
44+
discoveredSources: [],
45+
}),
46+
});
47+
});
48+
49+
await page.goto('/app/generate');
50+
await page.getByRole('button', { name: 'Generate Debate Case' }).click();
51+
await expect(page.getByText('Contention 3: fares block the riders who need transit most')).toBeVisible();
52+
53+
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
54+
expect(overflow, 'horizontal page overflow in px').toBeLessThanOrEqual(1);
55+
// The column must also stay inside the page container, which ends before the viewport edge on desktop.
56+
const results = await page.locator('#generate-results').boundingBox();
57+
const container = await page.locator('#generate-results').locator('xpath=ancestor::div[contains(@class, "max-w-6xl")]').boundingBox();
58+
expect(results && container && Math.round(results.x + results.width - (container.x + container.width))).toBeLessThanOrEqual(1);
59+
});

‎e2e/sparring.spec.ts‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -132,24 +132,8 @@ test('keeps the setup usable on mobile with reduced motion', async ({ page }) =>
132132
await page.emulateMedia({ reducedMotion: 'reduce' });
133133
await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' });
134134
await expect(page.getByRole('heading', { name: 'Sparring' })).toBeVisible();
135-
const logo = page.getByAltText('DebateLab logo');
136-
await expect(logo).toHaveAttribute('src', '/favicon-72.png');
137-
await expect(logo).toHaveAttribute('width', '36');
138-
await expect(logo).toHaveAttribute('height', '36');
139-
await expect.poll(() => logo.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);
140135
await expect(page.getByRole('combobox', { name: 'Round format' })).toHaveText('School 4-6-6-3 · 8 speeches');
141-
await expect(page.getByText('Write the resolution as you would hear it in the room.')).toHaveCount(0);
142-
await expect(page.getByText('A precise motion gives the opponent something concrete to attack.')).toHaveCount(0);
143136
await expect(page.getByText('Pro Introductor')).toBeHidden();
144-
const speakingSummary = page.locator('main details > summary');
145-
const summaryMetrics = await speakingSummary.evaluate((element) => {
146-
const icon = element.querySelector('svg');
147-
if (!icon) throw new Error('Speaking order disclosure is missing its chevron.');
148-
const summaryBox = element.getBoundingClientRect();
149-
const iconBox = icon.getBoundingClientRect();
150-
return Math.abs((summaryBox.top + summaryBox.height / 2) - (iconBox.top + iconBox.height / 2));
151-
});
152-
expect(summaryMetrics).toBeLessThanOrEqual(1);
153137
await page.getByText('Speaking order', { exact: true }).click();
154138
await expect(page.getByText('Pro Introductor')).toBeVisible();
155139
await expect(page.getByText('Pro Conclusor')).toBeVisible();

0 commit comments

Comments
 (0)