Skip to content

Commit c44366f

Browse files
committed
Merge remote-tracking branch 'origin/main' into blove/growth-hello-collectors
2 parents 06af0aa + 8c97a40 commit c44366f

9 files changed

Lines changed: 1112 additions & 961 deletions

File tree

344 Bytes
Loading
-5.04 KB
Loading

apps/website/src/components/landing/HeroDemo.spec.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,35 @@ describe('HeroDemo', () => {
107107
* Both posters are recorded artifacts, not build output, so a rename or a
108108
* lost file would ship a hero with a broken image and nothing would fail
109109
* until someone looked at the page.
110+
*
111+
* The phone poster's budget is ABSOLUTE, not a comparison against the desktop
112+
* poster. It was written as `mobile <= desktop` first, on the reasoning that a
113+
* phone downloads one instead of the other, and that coupling was wrong: both
114+
* files are re-recorded together, so re-recording the walkthrough shrank the
115+
* desktop capture 38.1KB -> 33.0KB and failed the phone poster for content it
116+
* does not contain. The phone poster is not justified on bytes anyway. Below
117+
* 768px `.hero-demo-stage` is `aspect-ratio: 3 / 5` with `object-fit: cover`,
118+
* so the 1200x720 desktop capture covering that portrait box shows about 36%
119+
* of its own width — the phone poster exists because that crop is unusable,
120+
* and it would still be worth shipping if it cost slightly more.
121+
*
122+
* The ceiling is what the recorder actually budgeted against when it chose to
123+
* ship 1.5x rather than 2x ("2x would cost ~51KB"): the mid-30s KB. Raise it
124+
* only with a reason, and never by simply pasting in whatever the file now
125+
* weighs — the point is to notice a poster that got expensive.
110126
*/
111-
it('ships both posters, with the phone one no heavier than the desktop one', async () => {
127+
const HERO_POSTER_MOBILE_MAX_BYTES = 36_000;
128+
129+
it('ships both posters, with the phone one inside its byte budget', async () => {
112130
const { HERO_POSTER, HERO_POSTER_MOBILE } = await import('./HeroDemo');
113131
const { resolveWebsiteDir } = await import('../../lib/website-dir');
114132
const { statSync } = await import('node:fs');
115133
const { join } = await import('node:path');
116134
const sizeOf = (publicPath: string) =>
117135
statSync(join(resolveWebsiteDir(), 'public', publicPath)).size;
118136
expect(sizeOf(HERO_POSTER)).toBeGreaterThan(0);
119-
// A phone downloads only this one, so it must not cost more than what it
120-
// replaces — the whole point is a lighter, legible LCP on the small screen.
121-
expect(sizeOf(HERO_POSTER_MOBILE)).toBeLessThanOrEqual(sizeOf(HERO_POSTER));
137+
expect(sizeOf(HERO_POSTER_MOBILE)).toBeGreaterThan(0);
138+
expect(sizeOf(HERO_POSTER_MOBILE)).toBeLessThanOrEqual(HERO_POSTER_MOBILE_MAX_BYTES);
122139
});
123140

124141
/**
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "Clean up our old database backups, anything older than 90 days.",
6+
"hasToolResult": true
7+
},
8+
"response": {
9+
"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."
10+
}
11+
},
12+
{
13+
"match": {
14+
"userMessage": "Clean up our old database backups, anything older than 90 days."
15+
},
16+
"response": {
17+
"toolCalls": [
18+
{
19+
"name": "request_approval",
20+
"arguments": {
21+
"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."
22+
}
23+
}
24+
]
25+
}
26+
}
27+
]
28+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Playwright config for recording the hero walkthrough fixture against the
3+
* REAL model, rather than the aimock replay `record-hero.config.ts` uses.
4+
*
5+
* `public/hero-replay.json` is shipped behind a pill that reads "Replaying a
6+
* recorded LangGraph run", so the words in it have to be words a model actually
7+
* said. Recording through aimock makes the stream events real but the prose
8+
* authored, which the pill then misrepresents.
9+
*
10+
* Unlike the other configs this one starts NOTHING — bring your own servers, so
11+
* that the OpenAI key stays in your shell and never reaches a committed file:
12+
*
13+
* # 1. backend on :2024, pointed at the real API
14+
* cd examples/chat/python && \
15+
* export OPENAI_API_KEY=$(grep -E '^OPENAI_API_KEY=' ../../../.env | cut -d= -f2-) && \
16+
* uv run langgraph dev --port 2024 --no-browser
17+
*
18+
* # 2. the demo on :4200 (dev build — /hero?record=1 is inert in production)
19+
* npx nx serve examples-chat-angular --port 4200
20+
*
21+
* # 3. one take
22+
* npx playwright test -c examples/chat/angular/e2e/record-hero-live.config.ts record-hero-fixture
23+
*
24+
* Takes vary: the model is free to answer how it likes, so record several and
25+
* commit the best COMPLETE one (the recorder's own assertions — three runs, an
26+
* approval_request, an a2ui payload — reject incomplete takes for you). Picking
27+
* a take is the same latitude a demo video has. Editing what the model said is
28+
* not: that is what this config exists to stop.
29+
*
30+
* The timeout is far longer than the replay config's because a real run streams
31+
* at model speed, with reasoning, instead of at aimock speed.
32+
*/
33+
import { defineConfig } from '@playwright/test';
34+
35+
export default defineConfig({
36+
testDir: '.',
37+
testMatch: '**/record-hero-*.record.ts',
38+
fullyParallel: false,
39+
workers: 1,
40+
retries: 0,
41+
reporter: 'list',
42+
timeout: 600_000,
43+
use: {
44+
baseURL: 'http://localhost:4200',
45+
viewport: { width: 1200, height: 720 },
46+
},
47+
outputDir: './.record-output',
48+
});

examples/chat/angular/e2e/record-hero-poster-mobile.record.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,23 @@
1313
* bubble behind it, and a still of a live Accept / Edit / Respond dialog
1414
* invites taps that do nothing.
1515
*
16-
* The 2500ms wait is shared with the desktop recorder and is about the scripted
16+
* The 2800ms wait is shared with the desktop recorder and is about the scripted
1717
* cursor, not the text: at 1500ms it is still parked where it pressed Accept,
1818
* which at phone width drops the arrowhead onto the word `retain` in step 3.
19-
* By 2500ms it has reached the composer, which reads as the walkthrough about
20-
* to type again rather than as a smudge on the prose.
19+
* By 2800ms it has reached the composer, which reads as the walkthrough about
20+
* to type again rather than as a smudge on the prose. See that recorder for the
21+
* measured timeline; PHONE WIDTH IS THE BINDING CONSTRAINT on the value, because
22+
* the composer here starts filling at ~2970ms while the desktop capture has
23+
* until ~3200ms. The previous 2500ms was measured against an older
24+
* `public/hero-replay.json` and dropped the arrowhead onto "Nothing has been
25+
* deleted yet" once that recording changed, so re-measure whenever it does.
26+
*
27+
* The height budget is just as coupled, and to the FIXTURE rather than the
28+
* replay: `e2e/fixtures/hero-approval.json` supplies the answer text, and its
29+
* opening line has to fit on ONE line at 390px (about 44 characters) or the
30+
* whole block shifts up and the first line is sliced off the top edge. A draft
31+
* that opened "Approved. Here is the cleanup I would run once you confirm the
32+
* backup locations:" wrapped to two lines and did exactly that.
2133
*
2234
* Geometry: 390x650 is the phone design width the reviews already use, and it
2335
* is exactly 3:5 — the ratio `.hero-demo-stage` holds below 768px — so
@@ -47,11 +59,14 @@ test('capture mobile hero poster', async ({ page }) => {
4759
const interruptPanel = page.locator('chat-interrupt-panel');
4860
await interruptPanel.waitFor({ timeout: 60_000 });
4961
await interruptPanel.waitFor({ state: 'detached', timeout: 60_000 });
50-
await page.waitForTimeout(2500);
62+
await page.waitForTimeout(2800);
5163
// Guards the beat: `.hero__take` ships in normal flow, and a composer with
52-
// the next prompt already typed into it means the wait has drifted late.
64+
// the next prompt already typed into it means the wait has drifted late. The
65+
// a2ui check catches a capture that drifted PAST typing into the second run,
66+
// where the composer has cleared again and would satisfy the check above.
5367
await expect(page.locator('.hero__take')).toBeVisible();
5468
await expect(page.locator('[data-hero-surface] textarea')).toHaveValue('');
69+
await expect(page.locator('a2ui-surface')).toHaveCount(0);
5570
const png = await page.screenshot({ type: 'png', fullPage: false });
5671
await sharp(png).resize({ width: SHIP_WIDTH }).webp({ quality: 55, effort: 6 }).toFile(OUT);
5772
console.log(`wrote ${OUT}`);

examples/chat/angular/e2e/record-hero-poster.record.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,38 @@
99
* user turn, the tool call, the rendered answer and the cursor heading back to
1010
* the composer — it reads as a product, not as a blank chat box.
1111
*
12-
* The wait is 2500ms rather than the 1500ms this script used to hold, because
13-
* the two land the scripted cursor in different places. At 1500ms it is still
14-
* parked where it pressed Accept, which on the phone capture put the arrowhead
15-
* on top of the word `retain` in step 3 — an artifact, not a hint that the demo
16-
* is live. HOLD_AFTER_ANSWER_MS (2000) plus CURSOR_MOVE_MS (650) puts it at the
17-
* composer at ~2650ms and typing starts immediately after, so 2500ms catches it
18-
* arriving with the composer still empty. The empty-composer assertion below is
19-
* what keeps a mistimed capture from shipping silently: the poster this
20-
* replaced had the second prompt already typed into it.
12+
* The wait exists to land the scripted cursor somewhere that is not on top of
13+
* the prose. It used to be 1500ms, which left the arrowhead parked where it
14+
* pressed Accept — on the phone capture, on top of the word `retain` in step 3,
15+
* an artifact rather than a hint that the demo is live.
16+
*
17+
* 2800ms is MEASURED, not derived. Instrumenting the walkthrough (sampling the
18+
* cursor's bounding box every 100ms from the moment the interrupt panel
19+
* detaches) gives this timeline, and it is close to but not the same as the
20+
* arithmetic HOLD_AFTER_ANSWER_MS + CURSOR_MOVE_MS would predict, because the
21+
* panel detaches partway through the resume run rather than at the click:
22+
*
23+
* ~0–2400ms parked at Accept
24+
* ~2400ms glide starts (600ms CSS transition on transform)
25+
* ~2650ms arrowhead clears the last line of the answer
26+
* ~3000ms glide ends at the composer
27+
* ~3200ms the second prompt starts typing
28+
*
29+
* So the frame is clean anywhere in ~2700–3150ms, and 2800ms sits in the middle
30+
* of that with the cursor low on its glide, reading as the walkthrough about to
31+
* type again. Both this and the phone recorder use the same number; the phone
32+
* window is the tighter of the two (its composer starts filling at ~2970ms),
33+
* which is what pins the value down. RE-MEASURE AFTER RE-RECORDING
34+
* `public/hero-replay.json`: the whole timeline hangs off that recording's event
35+
* timings, and the previous 2500ms stopped working when the replay was
36+
* re-recorded for a new first prompt.
37+
*
38+
* The assertions below are what keep a mistimed capture from shipping silently.
39+
* The empty composer catches a capture that drifted late into typing — the
40+
* poster this replaced had the second prompt already typed into it — and the
41+
* absent a2ui surface catches one that drifted so far that the second prompt
42+
* has been SENT, which would otherwise satisfy the empty-composer check on its
43+
* own once the composer cleared.
2144
*/
2245
import { expect, test } from '@playwright/test';
2346
import { resolve } from 'node:path';
@@ -32,12 +55,13 @@ test('capture hero poster', async ({ page }) => {
3255
const interruptPanel = page.locator('chat-interrupt-panel');
3356
await interruptPanel.waitFor({ timeout: 60_000 });
3457
await interruptPanel.waitFor({ state: 'detached', timeout: 60_000 });
35-
await page.waitForTimeout(2500);
58+
await page.waitForTimeout(2800);
3659
// The frame has to show what ships today, not a layout we have replaced.
3760
// `.hero__take` was moved out of absolute positioning and into normal flow;
3861
// the poster this replaced still had it floating over the composer.
3962
await expect(page.locator('.hero__take')).toBeVisible();
4063
await expect(page.locator('[data-hero-surface] textarea')).toHaveValue('');
64+
await expect(page.locator('a2ui-surface')).toHaveCount(0);
4165
const png = await page.screenshot({ type: 'png', fullPage: false });
4266
await sharp(png).webp({ quality: 82 }).toFile(OUT);
4367
console.log(`wrote ${OUT}`);

0 commit comments

Comments
 (0)