Skip to content

Test and deliver

  • Lesson 19
  • 55 min
  • Module 6: Security, accessibility and handoff

What you will be able to do

Prove that the desk works before you hand it over. You test each part with a fake API and run one end-to-end flow in a real browser, on a desktop and a phone screen. Then you check the production build and write down what the next developer needs.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

What you need on your computer

  • The project after Accessible interaction: npm test ends with ℹ pass 27 and Tests 48 passed (48).
  • About 200 MB of free disk space for the browser that Playwright downloads, and a network connection for that download.
  • Stop npm run api and npm run dev before the end-to-end steps: the flow starts its own servers on ports 8100 and 4173.

Tested on macOS (Apple silicon) with Node.js 24 in Chromium. The commands are the same on Windows and Linux, but there they are not tested. Firefox, Safari and real phones are not tested.

A car factory tests every part on a bench: the brakes, the lights, the engine, each one alone and many times. Then one driver takes the finished car for a drive on a real road. Before the sale, an inspector checks the papers, and the buyer gets a manual. No single check is enough. A perfect brake on the bench says nothing about a brake cable that was never connected.

The problem: 75 checks pass, and the desk shows nothing

Section titled “The problem: 75 checks pass, and the desk shows nothing”

Tomás wants the API’s logs to show which version of the desk sent each request. He adds one header in src/api/client.ts (a constructed change; do not keep it):

src/api/client.ts (Tomás's change)
headers: { 'X-Desk-Version': '1.0.0', ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...init.headers },

He runs npm test: ℹ pass 27 and Tests 48 passed (48). Every check is green. Then he opens the desk:

The ticket list with a red error box: The ticket service did not answer. Check your connection, then try again. Below it, a Try again button. No ticket rows.
With one extra header, every component test passes, but the real browser refuses every request.

The browser’s console explains it:

Access to fetch at 'http://127.0.0.1:8396/v1/tickets?limit=50' from origin 'http://localhost:4396' has been blocked by CORS policy: Request header field x-desk-version is not allowed by Access-Control-Allow-Headers in preflight response.

(The course team ran the desk on other ports; yours are 8100 and 5173.) A request with a header of its own makes the browser ask the API first, with an OPTIONS request called a preflight. The mock API allows only three headers:

Terminal window
curl -i -X OPTIONS http://127.0.0.1:8100/v1/tickets -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: x-desk-version"
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PATCH
Access-Control-Allow-Headers: Content-Type, Idempotency-Key, X-Request-ID

So why did 48 component tests pass? Because they never use a browser’s network. installFakeApi replaces fetch with a function that gives each test the answer it asks for. There is no API, no origin and no preflight. The fake tested every part on the bench, and the cable between the parts was never connected.

Each kind of check proves something that the others cannot:

  • A component test renders one part, such as the draft panel, in a simulated page (jsdom). It replaces the API with a fake: a stand-in that returns fixed answers. It is fast and can create any situation: a 404, a stream that breaks after two pieces, a slow answer. It cannot see the real API, the browser or the built files.
  • An end-to-end test (E2E) runs the real, built desk in a real browser against the running mock API. It does what a user does, by keyboard. It sees CORS, the lazy files, the real stream timing and a phone-sized screen. It is slow, so you keep only a few, for the main tasks.
  • Build checks look at what you deliver: the types, the production build, the secret check and the size report. They say nothing about behaviour, but a delivery without them is a guess.
Four stacked layers, narrow at the top and wide at the bottom. Top: before every delivery, npm run check: type check and production build in 0.6 s, secret check and size report in 0.1 s; proves the files build, no secret is in dist, the size is known; cannot see whether the screens work. Second: end to end with Playwright, 3 flows times 2 screens, 6 tests, about 27 s, with the production build, the mock API and Chromium, by keyboard; proves the parts work together with real requests and a phone screen; cannot see Firefox, Safari or a real screen reader. Third: components with Vitest and Testing Library, 48 tests, about 5 s, each part with a fake API; proves every state of each part; cannot see the real API, the browser or the build. Bottom: mock API and scripts with node:test, 27 tests, 0.2 s; proves that the practice API behaves as the contract says. A note: by hand, once per delivery, the keyboard and a screen reader on the real screens.
What each check of the desk proves, what it costs and what it cannot see. Many fast checks at the bottom, few slow ones at the top.
Text description of the diagram

The diagram has four layers, stacked from narrow at the top to wide at the bottom. The top layer is the check before every delivery, npm run check: the type check and the production build take 0.6 seconds, the secret check and the size report 0.1 seconds. It proves that the files build, that no secret is in dist, and what the size is, but it cannot see whether the screens work. The second layer is the end-to-end flow with Playwright: 3 flows on 2 screens, 6 tests in about 27 seconds, with the production build, the mock API and Chromium, by keyboard. It proves that the parts work together, with real requests, CORS and a phone screen, but it cannot see Firefox, Safari or a real screen reader. The third layer is the component tests with Vitest and Testing Library: 48 tests in about 5 seconds, each part with a fake API. They prove every state of each part: loading, error, stop, interrupted, refused. They cannot see the real API, the browser or the build. The bottom layer is the mock API and script checks with node:test: 27 tests in 0.2 seconds, which prove that the practice API behaves as the contract says. A note under the layers says that once per delivery, a person checks the real screens by hand, with the keyboard and a screen reader.

  • No browser
  • A real browser
One run of each check on the course team's laptop, with other work running. The 6 end-to-end tests take longer than the 75 other tests together.
Text description of the chart

A column chart of the time that one run of each check takes, in seconds. The 27 mock API and script checks take 0.2 seconds. The 48 component tests take 4.8 seconds. The build checks (type check, production build, secret check and size report) take 0.7 seconds. The 6 end-to-end tests take 26.9 seconds, more than all the other checks together. Your times will differ; the shape lasts.

The chart is the reason for the shape: a check that takes 27 seconds is run less often than one that takes 5. So you test every state with fast component tests, and you keep the end-to-end flow for what only a real browser can show.

A tiny example: one component test, line by line

Section titled “A tiny example: one component test, line by line”

Here is a test of the draft panel from Accessible interaction. Read every value:

src/features/draft/DraftPanel.test.tsx (one test)
test('a ticket without a draft says so', async () => {
installFakeApi(() => apiError(404, 'no_draft', 'No AI draft exists for this ticket.'));
render(<DraftPanel ticketId="T-30001" onUse={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: 'Write a draft' }));
expect(await screen.findByRole('status')).toHaveTextContent('Write the reply yourself');
});
  1. installFakeApi(…) replaces fetch. Every request now gets the answer 404 with the contract’s error shape: code no_draft.
  2. render(…) draws the panel for T-30001 in jsdom.
  3. getByRole('button', { name: 'Write a draft' }) finds the button the way a screen reader does: by role and name, from the accessibility tree. If the button lost its name, the test would fail.
  4. The test clicks it, then waits for the status, which must contain “Write the reply yourself”: the words that src/lib/errors.ts gives for no_draft.

The test proves that the panel turns a no_draft answer into the right words. It does not prove that the real API sends no_draft. That is the job of the mock API’s own check, “a ticket without a recorded draft gives 404 no_draft”. So the fake and the contract are tested on both sides of the cable, and the end-to-end flow checks the cable itself.

  1. Install Playwright’s test runner at an exact version:

    Terminal window
    npm install --save-dev --save-exact @playwright/test@1.64.0

    npm prints added 3 packages and found 0 vulnerabilities.

  2. Download the browser that Playwright drives:

    Terminal window
    npx playwright install chromium

    On the course team’s Mac, it printed Downloading Chrome for Testing 156.0.8078.4 (playwright chromium v1248) and downloaded 185.8 MiB. If the browser is already on your computer, the command finishes at once.

  3. Create playwright.config.ts in the project folder. It starts the mock API with the small data, and it builds and serves the production files. Then it runs every test on a desktop screen and a phone screen (Pixel 7 size):

    playwright.config.ts
    // One end-to-end flow in a real browser, against the production build and the mock API.
    // npx playwright install chromium (once: downloads the browser)
    // npm run test:e2e
    // Ports: E2E_API_PORT (8100) and E2E_WEB_PORT (4173), if those are busy on your computer.
    import { defineConfig, devices } from '@playwright/test';
    const apiPort = Number(process.env.E2E_API_PORT ?? 8100);
    const webPort = Number(process.env.E2E_WEB_PORT ?? 4173);
    const web = `http://localhost:${webPort}`;
    export default defineConfig({
    testDir: 'e2e',
    timeout: 60_000,
    workers: 1,
    retries: 0,
    reporter: [['list']],
    use: { baseURL: web, trace: 'retain-on-failure' },
    projects: [
    { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
    { name: 'phone', use: { ...devices['Pixel 7'] } },
    ],
    webServer: [
    {
    command: `node mock-api/server.mjs --size small --port ${apiPort}`,
    url: `http://127.0.0.1:${apiPort}/health`,
    env: { CORS_ORIGINS: web, MOCK_LOG: '0' },
    reuseExistingServer: false,
    },
    {
    command: `npx vite build && npx vite preview --port ${webPort} --strictPort`,
    url: web,
    env: { VITE_API_URL: `http://127.0.0.1:${apiPort}` },
    reuseExistingServer: false,
    timeout: 120_000,
    },
    ],
    });
  4. Create the folder e2e and the file e2e/desk.spec.ts. Its three tests are the main task by keyboard, a new ticket with its job, and a failing list that recovers:

    e2e/desk.spec.ts
    // The main task, by keyboard only: find a ticket, write a draft, use it, send the reply; then a
    // new ticket and its background job. Runs on a desktop screen and on a phone screen.
    import { expect, test } from '@playwright/test';
    test.beforeEach(async ({ request }) => {
    const api = `http://127.0.0.1:${process.env.E2E_API_PORT ?? 8100}`;
    await request.post(`${api}/admin/reset`);
    });
    test('answer a ticket with the AI draft, by keyboard only', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByText(/Showing \d+ tickets/)).toBeVisible();
    // Skip link, then the search box: Tab moves the focus, the keyboard types.
    await page.keyboard.press('Tab');
    await expect(page.getByRole('link', { name: 'Skip to the main content' })).toBeFocused();
    await page.getByRole('searchbox', { name: 'Search' }).focus();
    await page.keyboard.type('garden shed');
    const row = page.getByRole('link', { name: /T-80008/ });
    await expect(row).toBeVisible();
    await row.focus();
    await page.keyboard.press('Enter');
    // The focus moves to the ticket's heading.
    await expect(page.getByRole('heading', { level: 2, name: /where is my order LK-581106/i })).toBeFocused();
    // Write a draft with the keyboard; the focus stays on the same button.
    const write = page.getByRole('button', { name: 'Write a draft' });
    await write.focus();
    await page.keyboard.press('Enter');
    const status = page.locator('.draft [role="status"]');
    await expect(status).toHaveText('Draft finished. No sources.', { timeout: 15_000 });
    await expect(page.getByRole('button', { name: 'Write a new draft' })).toBeFocused();
    // The accessibility tree of the draft: what a screen reader user gets.
    await expect(page.locator('.draft')).toMatchAriaSnapshot(`
    - heading "AI draft" [level=3]
    - status: Draft finished. No sources.
    - button "Write a new draft"
    - button "Use this draft"
    - button "Discard"
    `);
    await page.getByRole('button', { name: 'Use this draft' }).focus();
    await page.keyboard.press('Enter');
    const reply = page.getByLabel('Reply to the customer');
    await expect(reply).toHaveValue(/garden shed/);
    await page.getByRole('button', { name: 'Send reply' }).focus();
    await page.keyboard.press('Enter');
    await expect(page.getByText('Sent.')).toBeVisible();
    await expect(page.locator('.message-agent').last()).toContainText('garden shed');
    });
    test('send a new ticket and follow its background job', async ({ page }) => {
    await page.goto('/#/new');
    await page.getByLabel('Customer ID').focus();
    await page.keyboard.type('C-0022');
    await page.keyboard.press('Tab');
    await page.keyboard.type('Parcel lost');
    await page.keyboard.press('Tab');
    await page.keyboard.type('My parcel did not arrive.');
    await page.keyboard.press('Tab');
    await page.keyboard.press('Enter');
    await expect(page.getByText(/Saved as T-500001/)).toBeFocused();
    await expect(page.getByText('The AI work is done.')).toBeVisible({ timeout: 20_000 });
    await expect(page.getByText(/Team: shipping/)).toBeVisible();
    });
    test('a slow, failing list says so in words and recovers', async ({ page, request }) => {
    const api = `http://127.0.0.1:${process.env.E2E_API_PORT ?? 8100}`;
    await request.post(`${api}/admin/mode`, { data: { list: 'fail' } });
    await page.goto('/');
    await expect(page.getByRole('alert')).toContainText('The ticket service is busy', { timeout: 15_000 });
    await request.post(`${api}/admin/mode`, { data: { list: 'normal' } });
    await page.getByRole('button', { name: 'Try again' }).click();
    await expect(page.getByText(/Showing \d+ tickets/)).toBeVisible();
    });

    toMatchAriaSnapshot compares the draft panel’s accessibility tree with the one that Camille needs. admin/reset gives every test the same starting data, and admin/mode makes the list fail on purpose (a simulated failure).

  5. In package.json, replace the whole scripts block. It adds test:e2e and check, and puts build:profile next to build:

    package.json (scripts)
    "scripts": {
    "api": "node mock-api/server.mjs",
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "build:profile": "tsc --noEmit && vite build --mode profile",
    "preview": "vite preview",
    "typecheck": "tsc --noEmit",
    "test": "node --test \"mock-api/*.test.mjs\" \"scripts/*.test.mjs\" && vitest run",
    "test:e2e": "playwright test",
    "size": "node scripts/bundle-report.mjs",
    "check:bundle": "node scripts/check-bundle.mjs",
    "check": "npm test && npm run build && npm run check:bundle && npm run size"
    }
  6. Run the flow:

    Terminal window
    npm run test:e2e
    Running 6 tests using 1 worker
    ✓ 1 [desktop] › e2e/desk.spec.ts:10:1 › answer a ticket with the AI draft, by keyboard only (2.9s)
    ✓ 2 [desktop] › e2e/desk.spec.ts:54:1 › send a new ticket and follow its background job (5.2s)
    ✓ 3 [desktop] › e2e/desk.spec.ts:69:1 › a slow, failing list says so in words and recovers (4.5s)
    ✓ 4 [phone] › e2e/desk.spec.ts:10:1 › answer a ticket with the AI draft, by keyboard only (2.9s)
    ✓ 5 [phone] › e2e/desk.spec.ts:54:1 › send a new ticket and follow its background job (5.2s)
    ✓ 6 [phone] › e2e/desk.spec.ts:69:1 › a slow, failing list says so in words and recovers (4.5s)
    6 passed (26.9s)

    Each test ran twice, on the desktop and on the phone screen. Playwright started and stopped both servers itself.

The phone screen showed the same panel in one narrow column, and the same keyboard steps worked there:

The AI draft panel on a phone-sized screen, 412 pixels wide. The state line Draft finished, the draft about the garden shed, the reason, the note that the draft has no sources, and the buttons Write a new draft and Use this draft on one line, with Discard below.
The draft panel on a phone-sized screen in Chromium. The buttons wrap to a second line; nothing is cut off.

Now put Tomás’s header back into client.ts for a moment, and run only the desktop tests: npx playwright test --project=desktop. All three fail. The first one stops where the list should appear:

✘ 1 [desktop] › e2e/desk.spec.ts:10:1 › answer a ticket with the AI draft, by keyboard only (5.1s)
✘ 2 [desktop] › e2e/desk.spec.ts:54:1 › send a new ticket and follow its background job (6.0s)
✘ 3 [desktop] › e2e/desk.spec.ts:69:1 › a slow, failing list says so in words and recovers (15.1s)
Error: expect(locator).toBeVisible() failed
Locator: getByText(/Showing \d+ tickets/)
Expected: visible
Timeout: 5000ms
Error: element(s) not found

The flow caught what 75 tests missed. Remove the header again. If the API really needs a new header, Amira adds it to the API’s Access-Control-Allow-Headers first.

When to use an end-to-end test, and what it costs. Write one for each task that the users cannot do without: here, answering a ticket and creating one. Do not write one for every state; the component tests cover those in seconds. An end-to-end test costs a browser download and about 27 seconds per run here. It also needs care: tests must start from known data (admin/reset), or they pass and fail by chance.

The last check before a delivery runs everything that needs no browser. First change two lines in scripts/bundle-report.mjs, so that its numbers agree with Vite’s. Then 1 kB is 1,000 bytes, as Vite prints, and gzip uses Node’s default level.

scripts/bundle-report.mjs (the size of each file)
return { file: p.slice(dir.length + 1), kb: data.length / 1000, gzipKb: gzipSync(data).length / 1000 };

Change KB to kB in the two console.log lines too, and add a comment line under the first line of the file: // 1 kB = 1,000 bytes, as Vite prints. Vite compresses a little differently: its gzip numbers differ by about 1%. Then run:

Terminal window
npm run check

The end of the output, after the tests and the build:

No secret found in 7 files of dist/.
File Size Gzip
assets/index-OCjOMpom.js 303.3 kB 92.4 kB
assets/DraftText-DPqcJ_ju.js 73.5 kB 24.6 kB
assets/LabsPage-n9IhhFGN.js 11.7 kB 3.8 kB
assets/NewTicketForm-C6gr9o0Z.js 4.2 kB 1.8 kB
assets/index-Cq7Ckq9E.css 4.2 kB 1.5 kB
assets/useJob-D5nSjGXD.js 0.9 kB 0.5 kB
index.html 0.4 kB 0.3 kB
JavaScript: 5 files, 123.1 kB gzip in all

Compare it with Load less work: the first screen still downloads one main file of about 92 kB. The new file DraftText (24.6 kB) arrives only with a finished draft.

Grace’s team will run the desk, and another developer will change it. They need two short documents, both in docs/. Write them as claims with evidence: each sentence says what is true and which check or measurement shows it.

  1. Create docs/handoff.md: how to run the desk, the checks before every delivery, what the screens promise, and the known limits:

    docs/handoff.md
    # Handoff: the ticket desk
    What the next developer needs to run, check and change the ticket desk. A worked example for the lesson *Test and deliver*.
    ## Run it
    1. `npm ci` (the exact versions of `package-lock.json`).
    2. `npm run api` in one terminal (the mock API, http://127.0.0.1:8100). In production, the real ticket API takes its place: set `VITE_API_URL` before the build.
    3. `npm run dev` in a second terminal (http://localhost:5173), or `npm run build` and `npm run preview` for the production files.
    ## Check it before every delivery
    | Check | Command | What it proves |
    |---|---|---|
    | Types | `npm run typecheck` | the code uses the API's data as the types say |
    | Component tests | `npm test` | each screen part does the right thing for a good answer, an error, a slow answer, a stop |
    | End to end | `npm run test:e2e` | one real flow by keyboard in a real browser, on a desktop and a phone screen |
    | Production build | `npm run build` | the code builds without errors |
    | No secret in the browser | `npm run check:bundle` | no server secret and no key-like text is in `dist/` |
    | Size | `npm run size` | the files that a browser downloads (compare with the last delivery) |
    `npm run check` runs the four commands that do not need a browser.
    ## What the screens promise
    - The list loads one server page (50 tickets) at a time and draws only the rows in view.
    - An old search answer never replaces a newer one (the cache key is the search).
    - A draft is shown as it is written; Stop, an interrupted connection, a failure and a finished draft look different and sound different to a screen reader. The finished draft is checked before it is trusted.
    - A status change shows at once and goes back if the server refuses it. A message to a customer is never shown as sent before the server says so.
    - The Markdown of a draft is shown without any HTML that can run code.
    ## Known limits
    - The keyboard can reach only the ticket rows that are drawn. The list box itself takes the focus and scrolls with the arrow keys; search is the fast way to a ticket.
    - After **Send reply**, the keyboard focus falls to the page: the button is disabled while the reply is sent, and nothing takes the focus back. A screen reader still hears "Sent." (a status). Fix it as the new-ticket form does: move the focus to the result.
    - The mock API has no sign-in. The authentication course adds sessions; the desk must then send its cookie with `credentials: 'include'`, and the API must allow it.
    - Tested in Chromium (desktop and a phone screen size). Not tested here: Firefox, Safari, real phones, Windows high-contrast mode.

    The known limits are as important as the promises. “Not tested here: Firefox, Safari, real phones” tells the next team where to look first. The limit about Send reply comes from the course team’s own keyboard run: it is a real gap, written down instead of hidden.

  2. Create docs/performance-note.md. It records what was measured, on which computer, what changed because of it, and what did not change:

    docs/performance-note.md
    # Performance note: the ticket desk
    A worked example for the lesson *Test and deliver*. It says what we measured, on which computer, what we changed because of it, and what we did not change. The numbers come from the course team's measurements in Chromium (from Playwright) on one laptop (Apple M4, 16 GB). Other computers give other numbers; the differences between the rows are what matter.
    ## What we measured and what we changed
    | Question | Before | After | What we changed |
    |---|---|---|---|
    | How much JavaScript does the first screen download? (gzip) | 123.2 kB in one file | 92.4 kB, then the rest only when it is needed | code splitting: the Markdown tools of the draft, the new-ticket form and the labs load later |
    | When do the first ticket rows appear on a phone-like connection? (150 ms round trip, 1.6 Mbit/s, CPU 4 times slower) | 1,257 ms | 1,113 ms | the same code splitting (on a fast connection: 98 ms and 97 ms, no real difference) |
    | How long does it take to draw 10,000 rows? | 274 ms, 60,002 elements in the page | 30 ms, 110 elements | the list draws only the rows in view (virtualization) |
    | Does an old search answer replace a newer one? | yes: 10 of 10 tries showed the results for "p" after we typed "parcel" | no: 0 of 10 | the query key is the search, and old requests are cancelled |
    | How late does the screen show that a background job is done? | 403 ms (polling every second, 5 requests) | 29 ms (server-sent events, 1 connection) | the job lab compares both; the new-ticket form polls, because it is simpler and its delay is small |
    ## What we did not change, and why
    - **Rows in `memo`.** Typing a note above 50 rows drew the rows 1,000 times, but that cost 2.4 ms for 20 key presses. Moving the note's state down to the note removed the extra drawing without `memo`. We keep the simple version.
    - **Drawing the draft once per frame.** Drawing every piece of a recorded draft took 30 commits; once per frame took 11. Both cost less than 0.01 ms of React work per draft on this computer. We keep one commit per piece, and we do not announce every piece to screen readers.
    - **Downloading every ticket.** The list asks the server for one page of 50 tickets at a time. Downloading all 10,000 took 6.5 s and 100 requests (about 2.4 MB of JSON) before the first row could be drawn.
    ## Check it again before a delivery
    `npm run build` and `npm run size` show the files and their sizes. Compare them with this note. Measure in the browser's Performance panel with the same throttling before you change a number here.

    The second table is the honest part: three ideas that sounded good and did not pay, with the numbers that showed it.

  3. In README.md, in the table of commands, change the npm test row and add three rows after npm run build:profile:

    README.md (the commands table)
    | `npm test` | runs the checks of the mock API, of the scripts and of the interface (Vitest and Testing Library) |
    | `npm run check:bundle` | after a build: fails if a secret is in the files that the browser downloads |
    | `npm run check` | everything before a delivery: tests, build, secret check, size report |
    | `npm run test:e2e` | one end-to-end flow by keyboard, in a real browser, on a desktop and a phone screen (first: `npx playwright install chromium`) |

    In the table of folders, replace the scripts/ row with these two rows:

    README.md (the folders table)
    | `e2e/` | the end-to-end flow (Playwright) |
    | `scripts/`, `docs/` | the size report and the secret check; written decisions, the handoff notes and the performance note (`docs/`) |
  4. Give the delivery its version number. This changes package.json and package-lock.json together:

    Terminal window
    npm version 1.0.0 --no-git-tag-version

    npm prints v1.0.0.

Your project now equals the snapshot end-of-m06, the end of the course’s project. If something differs, compare your files with it.

Common mistake

Executable doesn't exist

Symptom: npm run test:e2e fails at once, in 16 ms:

Error: browserType.launch: Executable doesn't exist at …/chromium_headless_shell-1248/chrome-headless-shell-mac-arm64/chrome-headless-shell
╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated. ║
║ Please run the following command to download new browsers: ║
║ ║
║ npx playwright install ║
╚════════════════════════════════════════════════════════════╝

Cause: npm installs Playwright’s code, not its browsers. Each Playwright version needs its own browser build.

Fix: run npx playwright install chromium, then run the tests again. Do it again after every update of @playwright/test.

Common mistake

http://localhost:4173 is already used

Symptom: the flow stops before any test runs: Error: http://localhost:4173 is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.

Cause: another program listens on the port, often an npm run preview that you forgot, or the mock API on 8100.

Fix: stop that program (Ctrl+C in its terminal). Or choose other ports for the flow: E2E_API_PORT=8200 E2E_WEB_PORT=4280 npm run test:e2e on macOS and Linux. In PowerShell: $env:E2E_API_PORT=8200; $env:E2E_WEB_PORT=4280; npm run test:e2e. The configuration passes the API’s address to the build and allows that origin in the mock API.

This is the module’s worked example. Do each step and compare your result with the one shown. It is the same sequence that a delivery follows every time.

  1. Start from a clean state: delete dist/, and delete .env if it still has a test value from the first lesson.
  2. Run npm run check. Expect ℹ pass 27, Tests 48 passed (48), the build, No secret found in 7 files of dist/. and JavaScript: 5 files, 123.1 kB gzip in all.
  3. Run npm run test:e2e. Expect 6 passed: three flows on the desktop and on the phone screen.
  4. Run the main task by hand, by keyboard only, as in Accessible interaction: skip link, search, ticket, draft, use, send. Expect the focus on the heading, then on the same draft button, and “Sent.” at the end.
  5. Open docs/performance-note.md. Check that each number in it has its computer and its conditions.

Grace’s agents often stop a draft that goes in the wrong direction. Deliver that task with evidence:

  1. A flow. Add e2e/stop.spec.ts: by keyboard, open T-80008, start a draft, stop it, and check the focus and the status. Use the simulated slow stream (admin/mode with { stream: 'slow' }), so that there is time to press Stop. It must pass on the desktop and on the phone screen.
  2. A measurement. Choose one number of the desk to measure in Chrome’s Performance panel, before and after one change of your own. Or measure the phone-like first rows again with throttling, as in Measure the browser. Add one row to docs/performance-note.md, with your computer and the conditions.
  3. The delivery. Run npm run check and npm run test:e2e, and add their last lines to docs/handoff.md under a heading “Delivered”.
Show a worked answer for the flow
e2e/stop.spec.ts
import { expect, test } from '@playwright/test';
const api = `http://127.0.0.1:${process.env.E2E_API_PORT ?? 8100}`;
test.beforeEach(async ({ request }) => {
await request.post(`${api}/admin/reset`);
});
test('stop a draft by keyboard, and hear that it stopped', async ({ page, request }) => {
// The slow stream (simulated: 60 ms per piece) leaves time to press Stop.
await request.post(`${api}/admin/mode`, { data: { stream: 'slow' } });
await page.goto('/#/tickets/T-80008');
await expect(page.getByRole('heading', { level: 2, name: /LK-581106/ })).toBeFocused();
await page.getByRole('button', { name: 'Write a draft' }).focus();
await page.keyboard.press('Enter');
const status = page.locator('.draft [role="status"]');
await expect(status).toHaveText('Writing the draft…');
await expect(page.getByRole('button', { name: 'Stop' })).toBeFocused();
await page.keyboard.press('Enter');
await expect(status).toHaveText('You stopped the draft. The text below is not complete.');
await expect(page.getByRole('button', { name: 'Write a new draft' })).toBeFocused();
await expect(page.getByRole('button', { name: 'Use this draft' })).toHaveCount(0);
});

The course team’s run:

✓ 4 [desktop] › e2e/stop.spec.ts:10:1 › stop a draft by keyboard, and hear that it stopped (896ms)
✓ 8 [phone] › e2e/stop.spec.ts:10:1 › stop a draft by keyboard, and hear that it stopped (880ms)
8 passed (29.6s)

The last check matters most: a stopped draft must not offer Use this draft, so an agent cannot send half a sentence. For the measurement, a good row names the question, the conditions (“Chrome 156, Slow 4G and CPU 4 times slower, empty cache”), the number before and after, and the change. A row that says only “faster” is not evidence.

Check your understanding

Tomás's extra header passed all 75 checks of npm test, but the desk showed no ticket. Why did the component tests miss it, and which check caught it?

Show the answer

The component tests replace fetch with a fake that returns fixed answers. No real request is sent, so there is no origin, no preflight and no CORS. The browser sent a preflight OPTIONS request because of the new header, and the mock API allows only Content-Type, Idempotency-Key and X-Request-ID. So the browser blocked every request. The end-to-end flow, with a real browser, the built files and the running mock API, failed at once: getByText(/Showing \d+ tickets/) was not found.

Check your understanding

Why does the desk have 48 component tests but only 3 end-to-end flows? Use the course team's times.

Show the answer

On the course team’s laptop, the 48 component tests took about 4.8 seconds. The 6 end-to-end tests (3 flows on 2 screens) took about 27 seconds. Component tests can create every state cheaply: a 404, a broken stream, a refused draft. So they test each state of each part. End-to-end tests are slow and need known data. So they cover only the main tasks, and what only a real browser shows: CORS, the built files, the real stream and a phone screen.

Check your understanding

Module check (completion): which commands show that the desk can be delivered, and what must each print? How do you show that the main task works by keyboard and that no secret is in the bundle?

Show the answer

npm run check must end with ℹ pass 27, Tests 48 passed (48), a build without errors, No secret found in 7 files of dist/. and JavaScript: 5 files, 123.1 kB gzip in all. npm run test:e2e must print 6 passed: its first flow does the main task by keyboard only, on a desktop and a phone screen, and checks the focus and the status at each step. By hand, do the same task by keyboard: the skip link first, the focus on the ticket’s heading, the same draft button, “Sent.” at the end. For secrets, also try the first lesson’s test: a VITE_ variable named like a key makes npm run check:bundle fail, and removing it makes it pass.

Recap

  • Component tests with a fake API test every state of every part in seconds. They cannot see the real API, the browser or the build: one extra header passed all 75 checks and blocked every request.
  • One end-to-end flow per main task, in a real browser, on the built files, on a desktop and a phone screen: 6 passed in about 27 seconds. It caught the header at once.
  • npm run check before every delivery: tests, build, secret check, size report.
  • The handoff is claims with evidence: how to run and check the desk, what the screens promise, the known limits, and a performance note with the computer, the conditions and the numbers, including the changes that did not pay.

Module recap. The desk now shows the model’s Markdown without running its code, and it keeps every secret out of the browser. It works by keyboard, tells a screen reader each change once, and is tested from its parts to one full flow. Your project equals end-of-m06.

Course recap. You built Larkfield’s ticket desk from a first HTML page to a delivered React application. It loads one page of tickets at a time and never shows an old search. It streams AI drafts with a Stop button and follows slow jobs. It shows sources and errors in words, and every speed-up has a measurement behind it. Next, choose a case study: at least one is required for the certificate.

Next: Fast search over a public dataset