Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified apps/website/public/screenshots/hero-walkthrough-poster-mobile.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/website/public/screenshots/hero-walkthrough-poster.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 21 additions & 4 deletions apps/website/src/components/landing/HeroDemo.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,35 @@ describe('HeroDemo', () => {
* Both posters are recorded artifacts, not build output, so a rename or a
* lost file would ship a hero with a broken image and nothing would fail
* until someone looked at the page.
*
* The phone poster's budget is ABSOLUTE, not a comparison against the desktop
* poster. It was written as `mobile <= desktop` first, on the reasoning that a
* phone downloads one instead of the other, and that coupling was wrong: both
* files are re-recorded together, so re-recording the walkthrough shrank the
* desktop capture 38.1KB -> 33.0KB and failed the phone poster for content it
* does not contain. The phone poster is not justified on bytes anyway. Below
* 768px `.hero-demo-stage` is `aspect-ratio: 3 / 5` with `object-fit: cover`,
* so the 1200x720 desktop capture covering that portrait box shows about 36%
* of its own width — the phone poster exists because that crop is unusable,
* and it would still be worth shipping if it cost slightly more.
*
* The ceiling is what the recorder actually budgeted against when it chose to
* ship 1.5x rather than 2x ("2x would cost ~51KB"): the mid-30s KB. Raise it
* only with a reason, and never by simply pasting in whatever the file now
* weighs — the point is to notice a poster that got expensive.
*/
it('ships both posters, with the phone one no heavier than the desktop one', async () => {
const HERO_POSTER_MOBILE_MAX_BYTES = 36_000;

it('ships both posters, with the phone one inside its byte budget', async () => {
const { HERO_POSTER, HERO_POSTER_MOBILE } = await import('./HeroDemo');
const { resolveWebsiteDir } = await import('../../lib/website-dir');
const { statSync } = await import('node:fs');
const { join } = await import('node:path');
const sizeOf = (publicPath: string) =>
statSync(join(resolveWebsiteDir(), 'public', publicPath)).size;
expect(sizeOf(HERO_POSTER)).toBeGreaterThan(0);
// A phone downloads only this one, so it must not cost more than what it
// replaces — the whole point is a lighter, legible LCP on the small screen.
expect(sizeOf(HERO_POSTER_MOBILE)).toBeLessThanOrEqual(sizeOf(HERO_POSTER));
expect(sizeOf(HERO_POSTER_MOBILE)).toBeGreaterThan(0);
expect(sizeOf(HERO_POSTER_MOBILE)).toBeLessThanOrEqual(HERO_POSTER_MOBILE_MAX_BYTES);
});

/**
Expand Down
28 changes: 28 additions & 0 deletions examples/chat/angular/e2e/fixtures/hero-approval.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"fixtures": [
{
"match": {
"userMessage": "Clean up our old database backups, anything older than 90 days.",
"hasToolResult": true
},
"response": {
"content": "Approved. Here is the cleanup I would run:\n\n1. **Inventory** the backup stores you name (S3, GCS, Azure, RDS and EBS snapshots, and the local backup table).\n2. **Dry run** a listing of everything older than 90 days so you can scan it before anything is touched.\n3. **Delete** the matched objects and snapshots, moving anything tagged `retain` to the archive bucket instead, and write an audit record for each deletion.\n\nNothing has been deleted yet. The dry-run listing comes first."
}
},
{
"match": {
"userMessage": "Clean up our old database backups, anything older than 90 days."
},
"response": {
"toolCalls": [
{
"name": "request_approval",
"arguments": {
"reason": "User requested to delete old database backups (destructive action). Requesting approval to proceed with deleting backups older than 90 days from production storage. Please confirm whether to proceed, and specify environment (production/staging), backup location/path, and any exclusions."
}
}
]
}
}
]
}
48 changes: 48 additions & 0 deletions examples/chat/angular/e2e/record-hero-live.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Playwright config for recording the hero walkthrough fixture against the
* REAL model, rather than the aimock replay `record-hero.config.ts` uses.
*
* `public/hero-replay.json` is shipped behind a pill that reads "Replaying a
* recorded LangGraph run", so the words in it have to be words a model actually
* said. Recording through aimock makes the stream events real but the prose
* authored, which the pill then misrepresents.
*
* Unlike the other configs this one starts NOTHING — bring your own servers, so
* that the OpenAI key stays in your shell and never reaches a committed file:
*
* # 1. backend on :2024, pointed at the real API
* cd examples/chat/python && \
* export OPENAI_API_KEY=$(grep -E '^OPENAI_API_KEY=' ../../../.env | cut -d= -f2-) && \
* uv run langgraph dev --port 2024 --no-browser
*
* # 2. the demo on :4200 (dev build — /hero?record=1 is inert in production)
* npx nx serve examples-chat-angular --port 4200
*
* # 3. one take
* npx playwright test -c examples/chat/angular/e2e/record-hero-live.config.ts record-hero-fixture
*
* Takes vary: the model is free to answer how it likes, so record several and
* commit the best COMPLETE one (the recorder's own assertions — three runs, an
* approval_request, an a2ui payload — reject incomplete takes for you). Picking
* a take is the same latitude a demo video has. Editing what the model said is
* not: that is what this config exists to stop.
*
* The timeout is far longer than the replay config's because a real run streams
* at model speed, with reasoning, instead of at aimock speed.
*/
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: '.',
testMatch: '**/record-hero-*.record.ts',
fullyParallel: false,
workers: 1,
retries: 0,
reporter: 'list',
timeout: 600_000,
use: {
baseURL: 'http://localhost:4200',
viewport: { width: 1200, height: 720 },
},
outputDir: './.record-output',
});
25 changes: 20 additions & 5 deletions examples/chat/angular/e2e/record-hero-poster-mobile.record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,23 @@
* bubble behind it, and a still of a live Accept / Edit / Respond dialog
* invites taps that do nothing.
*
* The 2500ms wait is shared with the desktop recorder and is about the scripted
* The 2800ms wait is shared with the desktop recorder and is about the scripted
* cursor, not the text: at 1500ms it is still parked where it pressed Accept,
* which at phone width drops the arrowhead onto the word `retain` in step 3.
* By 2500ms it has reached the composer, which reads as the walkthrough about
* to type again rather than as a smudge on the prose.
* By 2800ms it has reached the composer, which reads as the walkthrough about
* to type again rather than as a smudge on the prose. See that recorder for the
* measured timeline; PHONE WIDTH IS THE BINDING CONSTRAINT on the value, because
* the composer here starts filling at ~2970ms while the desktop capture has
* until ~3200ms. The previous 2500ms was measured against an older
* `public/hero-replay.json` and dropped the arrowhead onto "Nothing has been
* deleted yet" once that recording changed, so re-measure whenever it does.
*
* The height budget is just as coupled, and to the FIXTURE rather than the
* replay: `e2e/fixtures/hero-approval.json` supplies the answer text, and its
* opening line has to fit on ONE line at 390px (about 44 characters) or the
* whole block shifts up and the first line is sliced off the top edge. A draft
* that opened "Approved. Here is the cleanup I would run once you confirm the
* backup locations:" wrapped to two lines and did exactly that.
*
* Geometry: 390x650 is the phone design width the reviews already use, and it
* is exactly 3:5 — the ratio `.hero-demo-stage` holds below 768px — so
Expand Down Expand Up @@ -47,11 +59,14 @@ test('capture mobile hero poster', async ({ page }) => {
const interruptPanel = page.locator('chat-interrupt-panel');
await interruptPanel.waitFor({ timeout: 60_000 });
await interruptPanel.waitFor({ state: 'detached', timeout: 60_000 });
await page.waitForTimeout(2500);
await page.waitForTimeout(2800);
// Guards the beat: `.hero__take` ships in normal flow, and a composer with
// the next prompt already typed into it means the wait has drifted late.
// the next prompt already typed into it means the wait has drifted late. The
// a2ui check catches a capture that drifted PAST typing into the second run,
// where the composer has cleared again and would satisfy the check above.
await expect(page.locator('.hero__take')).toBeVisible();
await expect(page.locator('[data-hero-surface] textarea')).toHaveValue('');
await expect(page.locator('a2ui-surface')).toHaveCount(0);
const png = await page.screenshot({ type: 'png', fullPage: false });
await sharp(png).resize({ width: SHIP_WIDTH }).webp({ quality: 55, effort: 6 }).toFile(OUT);
console.log(`wrote ${OUT}`);
Expand Down
44 changes: 34 additions & 10 deletions examples/chat/angular/e2e/record-hero-poster.record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,38 @@
* user turn, the tool call, the rendered answer and the cursor heading back to
* the composer — it reads as a product, not as a blank chat box.
*
* The wait is 2500ms rather than the 1500ms this script used to hold, because
* the two land the scripted cursor in different places. At 1500ms it is still
* parked where it pressed Accept, which on the phone capture put the arrowhead
* on top of the word `retain` in step 3 — an artifact, not a hint that the demo
* is live. HOLD_AFTER_ANSWER_MS (2000) plus CURSOR_MOVE_MS (650) puts it at the
* composer at ~2650ms and typing starts immediately after, so 2500ms catches it
* arriving with the composer still empty. The empty-composer assertion below is
* what keeps a mistimed capture from shipping silently: the poster this
* replaced had the second prompt already typed into it.
* The wait exists to land the scripted cursor somewhere that is not on top of
* the prose. It used to be 1500ms, which left the arrowhead parked where it
* pressed Accept — on the phone capture, on top of the word `retain` in step 3,
* an artifact rather than a hint that the demo is live.
*
* 2800ms is MEASURED, not derived. Instrumenting the walkthrough (sampling the
* cursor's bounding box every 100ms from the moment the interrupt panel
* detaches) gives this timeline, and it is close to but not the same as the
* arithmetic HOLD_AFTER_ANSWER_MS + CURSOR_MOVE_MS would predict, because the
* panel detaches partway through the resume run rather than at the click:
*
* ~0–2400ms parked at Accept
* ~2400ms glide starts (600ms CSS transition on transform)
* ~2650ms arrowhead clears the last line of the answer
* ~3000ms glide ends at the composer
* ~3200ms the second prompt starts typing
*
* So the frame is clean anywhere in ~2700–3150ms, and 2800ms sits in the middle
* of that with the cursor low on its glide, reading as the walkthrough about to
* type again. Both this and the phone recorder use the same number; the phone
* window is the tighter of the two (its composer starts filling at ~2970ms),
* which is what pins the value down. RE-MEASURE AFTER RE-RECORDING
* `public/hero-replay.json`: the whole timeline hangs off that recording's event
* timings, and the previous 2500ms stopped working when the replay was
* re-recorded for a new first prompt.
*
* The assertions below are what keep a mistimed capture from shipping silently.
* The empty composer catches a capture that drifted late into typing — the
* poster this replaced had the second prompt already typed into it — and the
* absent a2ui surface catches one that drifted so far that the second prompt
* has been SENT, which would otherwise satisfy the empty-composer check on its
* own once the composer cleared.
*/
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
Expand All @@ -32,12 +55,13 @@ test('capture hero poster', async ({ page }) => {
const interruptPanel = page.locator('chat-interrupt-panel');
await interruptPanel.waitFor({ timeout: 60_000 });
await interruptPanel.waitFor({ state: 'detached', timeout: 60_000 });
await page.waitForTimeout(2500);
await page.waitForTimeout(2800);
// The frame has to show what ships today, not a layout we have replaced.
// `.hero__take` was moved out of absolute positioning and into normal flow;
// the poster this replaced still had it floating over the composer.
await expect(page.locator('.hero__take')).toBeVisible();
await expect(page.locator('[data-hero-surface] textarea')).toHaveValue('');
await expect(page.locator('a2ui-surface')).toHaveCount(0);
const png = await page.screenshot({ type: 'png', fullPage: false });
await sharp(png).webp({ quality: 82 }).toFile(OUT);
console.log(`wrote ${OUT}`);
Expand Down
Loading
Loading