Skip to content
Open
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
7 changes: 7 additions & 0 deletions runner/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ What a green E2E run does and does not prove:
(`FIXTURE_ID` in the spec — currently `r-react-18-0-0`). Never revoke it; if it is lost,
mint a replacement titled "E2E fixture — do not revoke" from any signed-in session and
update the constant.
- **The authed write round-trip needs `E2E_BROKER_TOKEN`** (`share-create-live.spec.ts`):
a fresh `sessionStorage.hot_token` from a signed-in session on the deployed app. Broker
tokens expire and cannot be minted programmatically, so the spec self-skips without one
and the workflow treats an expired token as a warning, not a failure. It creates one
real demo and revokes it in `finally` (the 410 doubles as the revocation assertion).
- **`E2E_AI=1` gates the live LLM answer checks** (`ai-live.spec.ts`): two API-level calls
per run, real budget, shared 8/min-per-IP rate bucket — a 429 skips rather than fails.

## Build & deploy

Expand Down
95 changes: 95 additions & 0 deletions runner/e2e/share-create-live.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test, expect } from "@playwright/test";
import { workspaceFiles } from "./helpers";

// The authed share round-trip, for real (DEV-2203): create → build → view →
// revoke, against a deployed worker. Everything else about sharing is proved
// with stubs (authed-actions.spec.ts) or read-only against a fixture
// (share-view.spec.ts); this is the one test that exercises the builder, R2
// and D1 end to end.
//
// It needs a real broker token — there is no test bypass in the deployed
// worker, by design (auth.ts re-validates every bearer against the broker and
// requires @handsontable.com). Broker tokens are per-user session JWTs with an
// expiry and no programmatic mint path, so the token arrives as a secret you
// refresh by hand when you want this to run:
//
// 1. Sign in on the deployed app, then in the console: sessionStorage.hot_token
// 2. E2E_BROKER_TOKEN=<that> E2E_BASE_URL=https://demos.handsontable.com \
// pnpm e2e e2e/share-create-live.spec.ts --workers=1
//
// Cost and hygiene: one BuilderSandbox boot (pool of 3, shared) and one D1
// row per run. The revoke in `finally` is both the cleanup and the last
// assertion — a revoked share must answer 410, and rows are soft-deleted so
// the worst-case leak is one revoked row.

const TOKEN = process.env.E2E_BROKER_TOKEN;

test("a demo shared today is a page a client can open — until it is revoked", async ({ page, request, baseURL }) => {
test.skip(!process.env.E2E_BASE_URL, "needs a deployed API origin");
test.skip(!TOKEN, "set E2E_BROKER_TOKEN to a fresh sessionStorage.hot_token from a signed-in session");
test.setTimeout(420_000);

// The real token, the real broker, no stubs.
await page.addInitScript((token) => sessionStorage.setItem("hot_token", token), TOKEN!);

// The id is captured off the network, not the dialog: a locator throwing
// between the mint and the id read would leave a live production share with
// nothing to revoke it (Bugbot, #186 — getByLabel(/client link/i) also
// matched the "Copy Public client link" button and strict mode threw).
let demoId: string | null = null;
page.on("response", async (res) => {
if (res.request().method() === "POST" && /\/api\/demos$/.test(res.url()) && res.ok()) {
const body = (await res.json().catch(() => null)) as { id?: string } | null;
if (body?.id) demoId = body.id;
}
});

try {
await page.goto("/?example=react");
// Fork appearing in the top bar is the signed-in signal — assert it before
// acting, so an expired token reads as "token expired", not a dead button.
await expect(
page.getByRole("button", { name: "Fork", exact: true }),
"no authed top bar — is E2E_BROKER_TOKEN expired?",
).toBeVisible({ timeout: 30_000 });

// Auth is not the only precondition: the workspace starts as an empty
// placeholder and fills asynchronously (and can refill when /api/versions
// swaps in `latest`). Minting in that window posts empty files and burns
// the whole dialog budget on a doomed build (Bugbot, #186) — so wait for
// the starter to actually be here before sharing.
await expect(async () => {
const files = await workspaceFiles(page);
expect(files["/package.json"], "the starter workspace has loaded").toBeTruthy();
}).toPass({ timeout: 30_000 });

// Share mints a demo: POST /api/demos runs a real container build and
// only then hands back links, so the dialog opening means "built".
await page.getByRole("button", { name: "Share this demo" }).click();
const dialog = page.getByRole("dialog", { name: "Share this demo" });
await expect(dialog).toBeVisible({ timeout: 300_000 });
Comment thread
cursor[bot] marked this conversation as resolved.

// The textbox role, not getByLabel: the field's copy button is named
// "Copy Public client link" and would collide under strict mode.
const clientLink = await dialog.getByRole("textbox", { name: /client link/i }).inputValue();
expect(demoId, "the mint response carried a demo id").toBeTruthy();
expect(clientLink, "the dialog's client link names the minted demo").toContain(demoId!);

// The built page renders for an anonymous client (fresh context state not
// needed — /d is public and static, cookies play no part).
await page.goto(`/d/${demoId}/`);
await expect
.poll(async () => page.locator(".handsontable .htCore td").count(), { timeout: 60_000 })
.toBeGreaterThan(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test timeout leaves no margin

Medium Severity

test.setTimeout(420_000) matches the sum of the explicit waits (30s + 30s + 300s + 60s) with no room for navigation, the Share click, link reads, or the revoke work in finally. A build that uses the full 300s dialog budget will hit the overall timeout before view/revoke finish, failing the run and risking an unreaved live share.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 51392e0. Configure here.

} finally {
if (demoId) {
// Revoke is the cleanup *and* the final assertion.
const del = await request.delete(`${baseURL}/api/demos/${demoId}`, {
headers: { Authorization: `Bearer ${TOKEN}` },
});
expect(del.status(), "the owner can revoke their own demo").toBe(204);
const after = await request.get(`${baseURL}/d/${demoId}/`);
expect(after.status(), "a revoked share answers 410, not a stale page").toBe(410);
}
}
});
Loading