Skip to content

Commit 1a2492b

Browse files
committed
test(catalog): add Playwright e2e coverage
1 parent 1446bbf commit 1a2492b

12 files changed

Lines changed: 601 additions & 2 deletions

File tree

.github/workflows/e2e-pr.yml

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
name: E2E PR
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
workflow_dispatch:
7+
inputs:
8+
ref:
9+
description: Git ref to test
10+
required: false
11+
type: string
12+
13+
permissions:
14+
contents: read
15+
pull-requests: read
16+
17+
jobs:
18+
prepare-pr:
19+
if: >
20+
github.event_name == 'issue_comment' &&
21+
github.event.issue.pull_request &&
22+
startsWith(github.event.comment.body, '/e2e')
23+
runs-on: ubuntu-latest
24+
outputs:
25+
head_ref: ${{ steps.pr.outputs.head_ref }}
26+
head_sha: ${{ steps.pr.outputs.head_sha }}
27+
head_repo: ${{ steps.pr.outputs.head_repo }}
28+
pr_number: ${{ steps.pr.outputs.pr_number }}
29+
steps:
30+
- name: Read pull request metadata
31+
id: pr
32+
uses: actions/github-script@v7
33+
with:
34+
script: |
35+
const { data: pr } = await github.rest.pulls.get({
36+
owner: context.repo.owner,
37+
repo: context.repo.repo,
38+
pull_number: context.issue.number,
39+
});
40+
41+
core.setOutput("head_ref", pr.head.ref);
42+
core.setOutput("head_sha", pr.head.sha);
43+
core.setOutput("head_repo", pr.head.repo.full_name);
44+
core.setOutput("pr_number", String(pr.number));
45+
46+
e2e-from-comment:
47+
needs: prepare-pr
48+
if: needs.prepare-pr.result == 'success'
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 30
51+
steps:
52+
- name: Checkout PR branch
53+
uses: actions/checkout@v4
54+
with:
55+
repository: ${{ needs.prepare-pr.outputs.head_repo }}
56+
ref: ${{ needs.prepare-pr.outputs.head_sha }}
57+
58+
- name: Use Bun
59+
uses: oven-sh/setup-bun@v2
60+
with:
61+
bun-version: "1.3.10"
62+
63+
- name: Install
64+
run: bun install --frozen-lockfile
65+
66+
- name: Install Playwright Chromium
67+
run: bunx playwright install --with-deps chromium
68+
69+
- name: Run E2E
70+
run: bun run e2e
71+
72+
- name: Upload Playwright report
73+
if: always()
74+
uses: actions/upload-artifact@v4
75+
with:
76+
name: playwright-report-pr-${{ needs.prepare-pr.outputs.pr_number }}
77+
path: |
78+
playwright-report
79+
test-results
80+
if-no-files-found: ignore
81+
82+
e2e-manual:
83+
if: github.event_name == 'workflow_dispatch'
84+
runs-on: ubuntu-latest
85+
timeout-minutes: 30
86+
steps:
87+
- name: Checkout selected ref
88+
uses: actions/checkout@v4
89+
with:
90+
ref: ${{ inputs.ref || github.ref }}
91+
92+
- name: Use Bun
93+
uses: oven-sh/setup-bun@v2
94+
with:
95+
bun-version: "1.3.10"
96+
97+
- name: Install
98+
run: bun install --frozen-lockfile
99+
100+
- name: Install Playwright Chromium
101+
run: bunx playwright install --with-deps chromium
102+
103+
- name: Run E2E
104+
run: bun run e2e
105+
106+
- name: Upload Playwright report
107+
if: always()
108+
uses: actions/upload-artifact@v4
109+
with:
110+
name: playwright-report-manual-${{ github.run_id }}
111+
path: |
112+
playwright-report
113+
test-results
114+
if-no-files-found: ignore

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
# next.js
88
/.next/
9+
/.next-e2e/
910
/out/
11+
/playwright-report/
12+
/test-results/
1013

1114
# env files (can opt-in for committing if needed)
1215
.env*
@@ -23,3 +26,4 @@ next-env.d.ts
2326
/scripts/*Test/*
2427

2528
certificates
29+
e2e/.auth/

app/e2e/page.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"use client";
2+
3+
import { atom, useAtomValue } from "jotai";
4+
import { unwrap } from "jotai/utils";
5+
import { useEffect, useState } from "react";
6+
import { deviceEvoluAtom } from "@/atoms/device-evolu";
7+
import {
8+
bootstrapE2eAccount,
9+
type CatalogSeedResult,
10+
type CatalogSeedScenario,
11+
seedCatalogScenario,
12+
} from "@/lib/testing/e2e-catalog";
13+
14+
const loadingState = { state: "loading" } as const;
15+
const unwrappedDeviceEvoluAtom = unwrap(deviceEvoluAtom, () => loadingState);
16+
const loadableDeviceEvoluAtom = atom((get) => {
17+
try {
18+
const data = get(unwrappedDeviceEvoluAtom);
19+
if (data === loadingState) {
20+
return loadingState;
21+
}
22+
23+
return { state: "hasData", data } as const;
24+
} catch (error) {
25+
return { state: "hasError", error } as const;
26+
}
27+
});
28+
29+
declare global {
30+
interface Window {
31+
__finitoE2E?: {
32+
bootstrap: () => Promise<{
33+
deviceId: string;
34+
mnemonic: string;
35+
}>;
36+
seedCatalogScenario: (
37+
scenario: CatalogSeedScenario,
38+
) => Promise<CatalogSeedResult>;
39+
};
40+
}
41+
}
42+
43+
export default function Page() {
44+
const deviceEvoluState = useAtomValue(loadableDeviceEvoluAtom);
45+
const [status, setStatus] = useState("loading");
46+
const [lastResult, setLastResult] = useState<string>("");
47+
48+
useEffect(() => {
49+
if (deviceEvoluState.state !== "hasData") {
50+
setStatus(deviceEvoluState.state === "hasError" ? "error" : "loading");
51+
delete window.__finitoE2E;
52+
return;
53+
}
54+
55+
window.__finitoE2E = {
56+
bootstrap: async () => {
57+
setStatus("running");
58+
const result = await bootstrapE2eAccount(deviceEvoluState.data);
59+
const payload = {
60+
deviceId: result.device.id,
61+
mnemonic: result.mnemonic,
62+
};
63+
setLastResult(JSON.stringify(payload));
64+
setStatus("ready");
65+
return payload;
66+
},
67+
seedCatalogScenario: async (scenario) => {
68+
setStatus("running");
69+
const result = await seedCatalogScenario(
70+
deviceEvoluState.data,
71+
scenario,
72+
);
73+
setLastResult(JSON.stringify(result));
74+
setStatus("ready");
75+
return result;
76+
},
77+
};
78+
79+
setStatus("ready");
80+
81+
return () => {
82+
delete window.__finitoE2E;
83+
};
84+
}, [deviceEvoluState]);
85+
86+
const errorMessage =
87+
deviceEvoluState.state === "hasError"
88+
? String(deviceEvoluState.error)
89+
: undefined;
90+
91+
return (
92+
<main className="mx-auto flex min-h-dvh w-full max-w-3xl flex-col gap-4 p-6 font-mono text-sm">
93+
<h1 className="text-lg font-semibold">Finito E2E Harness</h1>
94+
<p data-testid="e2e-status">status:{status}</p>
95+
{errorMessage ? <p data-testid="e2e-error">{errorMessage}</p> : null}
96+
<pre
97+
data-testid="e2e-last-result"
98+
className="overflow-auto rounded border p-4"
99+
>
100+
{lastResult || "{}"}
101+
</pre>
102+
</main>
103+
);
104+
}

bun.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/catalog.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { expect, test } from "@playwright/test";
2+
import { seedCatalog } from "./helpers/harness";
3+
4+
const createUniqueLabel = (prefix: string) =>
5+
`${prefix} ${Date.now()} ${Math.round(Math.random() * 10_000)}`;
6+
7+
test("shows a seeded catalog item in the list and opens its detail", async ({
8+
page,
9+
}) => {
10+
const label = createUniqueLabel("Catalog smoke");
11+
const seed = await seedCatalog(page, {
12+
name: "single-item",
13+
item: {
14+
label,
15+
},
16+
});
17+
if (!seed.item) {
18+
throw new Error("Expected a seeded item for the smoke scenario.");
19+
}
20+
21+
await page.goto("/admin/catalog");
22+
await expect(page.getByRole("link", { name: label })).toBeVisible();
23+
24+
await page.getByRole("link", { name: label }).click();
25+
26+
await expect(page).toHaveURL(new RegExp(`/admin/catalog/detail\\?id=${seed.item.id}`));
27+
await expect(page.getByRole("heading", { name: label })).toBeVisible();
28+
await expect(page.getByText("Record ID")).toBeVisible();
29+
await expect(page.getByText(seed.item.id)).toBeVisible();
30+
});
31+
32+
test("creates a new catalog item from the list page", async ({ page }) => {
33+
const label = createUniqueLabel("Catalog create");
34+
35+
await seedCatalog(page, {
36+
name: "empty-catalog",
37+
});
38+
39+
await page.goto("/admin/catalog");
40+
await Promise.all([
41+
page.waitForURL(/\/admin\/catalog\/new$/, { timeout: 20_000 }),
42+
page.getByRole("link", { name: "New item" }).click(),
43+
]);
44+
await page.locator('[name="label"]').fill(label);
45+
await page.locator('[name="price"]').fill("123");
46+
await page.getByRole("button", { name: "Save" }).click();
47+
48+
await expect(page).toHaveURL(/\/admin\/catalog$/, { timeout: 20_000 });
49+
await expect(page.getByRole("link", { name: label })).toBeVisible();
50+
});
51+
52+
test("deletes a catalog item from the detail menu", async ({ page }) => {
53+
const label = createUniqueLabel("Catalog delete");
54+
const seed = await seedCatalog(page, {
55+
name: "single-item",
56+
item: {
57+
label,
58+
},
59+
});
60+
if (!seed.item) {
61+
throw new Error("Expected a seeded item for the delete scenario.");
62+
}
63+
64+
await page.goto(`/admin/catalog/detail?id=${seed.item.id}`);
65+
await page.getByRole("button", { name: "Actions" }).click();
66+
await page.getByRole("menuitem", { name: "Delete" }).click();
67+
await page
68+
.getByRole("alertdialog")
69+
.getByRole("button", { name: "Delete" })
70+
.click();
71+
72+
await expect(page).toHaveURL(/\/admin\/catalog$/);
73+
await expect(page.getByRole("link", { name: label })).toHaveCount(0);
74+
});

0 commit comments

Comments
 (0)