diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38c1aadb..fac18794 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,3 +66,40 @@ jobs: - name: Display test coverage run: go tool cover -func=coverage.out + + test-e2e: + runs-on: ubuntu-latest + + steps: + - name: Setup repo + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Install dependencies + run: npm ci + + - name: Build + run: make build + + - name: Install Playwright browsers + run: npx playwright install chromium + + - name: Run e2e tests + run: npx playwright test + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index d1019915..08c90c28 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ silverbullet deploy.json *.generated tmp_playground +test-results +playwright-report /public_version.ts website/_plug tmp diff --git a/Makefile b/Makefile index e2729ab3..459f8439 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ LDFLAGS = -X main.buildTime=$$(date -u +%Y-%m-%dT%H:%M:%SZ) -.PHONY: build build-for-docker docker build-server-releases clean check fmt test test-integration bench generate website +.PHONY: build build-for-docker docker build-server-releases clean check fmt test test-integration test-e2e bench generate website build: # Build client @@ -55,6 +55,9 @@ test-integration: # Run headless Chrome integration tests (requires Chrome installed) go test -tags=integration ./server/... -v -timeout 300s +test-e2e: build + npx playwright test + bench: # Run frontend benchmarks npm run bench diff --git a/e2e/command-palette.test.ts b/e2e/command-palette.test.ts new file mode 100644 index 00000000..3383897d --- /dev/null +++ b/e2e/command-palette.test.ts @@ -0,0 +1,55 @@ +import { expect, mod, test } from "./fixtures.ts"; + +test.describe("Command palette", () => { + test("open and close command palette", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); + + // Open command palette + await sbPage.keyboard.press(`${mod}+/`); + const modal = sbPage.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); + + // Close with Escape + await sbPage.keyboard.press("Escape"); + await expect(modal).not.toBeVisible(); + }); + + test("filter commands by typing", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); + + // Open command palette + await sbPage.keyboard.press(`${mod}+/`); + const modal = sbPage.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); + + // Type to filter + const paletteInput = modal.locator(".cm-content"); + await paletteInput.click(); + await sbPage.keyboard.type("Stats", { delay: 30 }); + + // Should show filtered results including "Stats: Show" + await expect(modal.locator(".sb-option .sb-name", { hasText: "Stats" })).toBeVisible(); + + await sbPage.keyboard.press("Escape"); + }); + + test("run a command from the palette", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); + + // Open command palette and run "Stats: Show" + await sbPage.keyboard.press(`${mod}+/`); + const modal = sbPage.locator(".sb-modal-box"); + const paletteInput = modal.locator(".cm-content"); + await paletteInput.click(); + await sbPage.keyboard.type("Stats: Show", { delay: 30 }); + + // Select the matching command + await sbPage.keyboard.press("Enter"); + + // The modal should close + await expect(modal).not.toBeVisible(); + }); +}); diff --git a/e2e/editor-formatting.test.ts b/e2e/editor-formatting.test.ts new file mode 100644 index 00000000..a156f8cb --- /dev/null +++ b/e2e/editor-formatting.test.ts @@ -0,0 +1,87 @@ +import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; + +test.describe("Editor formatting", () => { + test("bold text with Mod+B", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); + + // Navigate to a fresh page + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.keyboard.type("Formatting Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); + + // Type some text + await editor.click(); + await sbPage.keyboard.type("make this bold"); + + // Select all + await sbPage.keyboard.press(`${mod}+a`); + + // Apply bold + await sbPage.keyboard.press(`${mod}+b`); + + // Verify bold markers appear + await expect(editor).toContainText("**make this bold**"); + + // Verify saved to server + const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Formatting Test.md"); + expect(content).toContain("**make this bold**"); + }); + + test("italic text with Mod+I", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); + + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.keyboard.type("Italic Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); + + await editor.click(); + await sbPage.keyboard.type("make this italic"); + await sbPage.keyboard.press(`${mod}+a`); + await sbPage.keyboard.press(`${mod}+i`); + + await expect(editor).toContainText("_make this italic_"); + + const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Italic Test.md"); + expect(content).toContain("_make this italic_"); + }); + + test("bullet list with Mod+Shift+8", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); + + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.keyboard.type("List Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); + + // Type multiple lines + await editor.click(); + await sbPage.keyboard.type("First item"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.type("Second item"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.type("Third item"); + + // Select all and make it a bullet list + await sbPage.keyboard.press(`${mod}+a`); + await sbPage.keyboard.press(`${mod}+Shift+8`); + + // Verify bullet markers + await expect(editor).toContainText("* First item"); + await expect(editor).toContainText("* Second item"); + await expect(editor).toContainText("* Third item"); + + // Verify saved to server + const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "List Test.md"); + expect(content).toContain("* First item"); + expect(content).toContain("* Second item"); + expect(content).toContain("* Third item"); + }); +}); diff --git a/e2e/first-load.test.ts b/e2e/first-load.test.ts new file mode 100644 index 00000000..a9d33eaf --- /dev/null +++ b/e2e/first-load.test.ts @@ -0,0 +1,29 @@ +import { expect, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; + +test.describe("First load on empty space", () => { + test("page loads and shows editor", async ({ sbPage }) => { + await expect(sbPage.locator("#sb-editor")).toBeVisible(); + await expect(sbPage.locator("#sb-editor .cm-editor")).toBeVisible(); + }); + + test("welcome page content appears", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + }); + + test("editor is editable and saves to server", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + + // Move to end of document, then type on a new line + await editor.click(); + await sbPage.keyboard.press("End"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.type("Hello from Playwright"); + await expect(editor).toContainText("Hello from Playwright"); + + // Verify the edit was saved to the server + const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "index.md"); + expect(content).toContain("Hello from Playwright"); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 00000000..ff54f232 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,139 @@ +import { test as base, type Page } from "@playwright/test"; +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; +import { platform, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +/** The platform-appropriate modifier key: Meta on macOS, Control elsewhere. */ +export const mod = platform() === "darwin" ? "Meta" : "Control"; + +export type SBServer = { + url: string; + port: number; + spaceDir: string; +}; + +type SBFixtures = { + spaceFiles: Record; + sbServer: SBServer; + sbPage: Page; + sbPageWithSync: Page; +}; + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address() as net.AddressInfo; + srv.close(() => resolve(addr.port)); + }); + srv.on("error", reject); + }); +} + +async function waitForServer(url: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const resp = await fetch(url); + if (resp.ok) return; + } catch { + // server not ready yet + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`Server did not become ready at ${url} within ${timeoutMs}ms`); +} + +export const test = base.extend({ + spaceFiles: [{}, { option: true }], + + sbServer: async ({ spaceFiles }, use) => { + const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-")); + + // Seed space with files + for (const [path, content] of Object.entries(spaceFiles)) { + const fullPath = join(spaceDir, path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); + } + + const port = await getFreePort(); + + const proc: ChildProcess = spawn( + "./silverbullet", + [spaceDir, "-p", String(port), "-L", "127.0.0.1"], + { + cwd: join(import.meta.dirname, ".."), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let serverOutput = ""; + proc.stdout?.on("data", (d: Buffer) => { + serverOutput += d.toString(); + }); + proc.stderr?.on("data", (d: Buffer) => { + serverOutput += d.toString(); + }); + + const url = `http://127.0.0.1:${port}`; + + try { + await waitForServer(`${url}/.ping`); + } catch (err) { + proc.kill("SIGKILL"); + throw new Error(`Server failed to start. Output:\n${serverOutput}\n${err}`); + } + + await use({ url, port, spaceDir }); + + // Cleanup + proc.kill("SIGTERM"); + await new Promise((resolve) => { + const timer = setTimeout(() => { + proc.kill("SIGKILL"); + resolve(); + }, 5000); + proc.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + await rm(spaceDir, { recursive: true, force: true }); + }, + + sbPage: async ({ sbServer, page }, use) => { + await page.goto(`${sbServer.url}/?enableSW=0`); + await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); + await use(page); + }, + + sbPageWithSync: async ({ sbServer, page }, use) => { + await page.goto(sbServer.url); + await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); + await use(page); + }, +}); + +/** + * Wait for the client to save (indicated by #sb-current-page having class "sb-saved"), + * then fetch the page content from the server's filesystem API. + */ +export async function waitForSaveAndReadFromServer( + page: Page, + sbServer: SBServer, + pagePath: string, +): Promise { + // Wait for the save indicator — "sb-saved" class on the page name element + await page.locator("#sb-current-page.sb-saved").waitFor({ state: "attached", timeout: 10_000 }); + // Fetch the file from the server + const resp = await fetch(`${sbServer.url}/.fs/${pagePath}`); + if (!resp.ok) { + throw new Error(`Failed to read ${pagePath} from server: ${resp.status}`); + } + return resp.text(); +} + +export { expect } from "@playwright/test"; diff --git a/e2e/page-navigation.test.ts b/e2e/page-navigation.test.ts new file mode 100644 index 00000000..93b83f6c --- /dev/null +++ b/e2e/page-navigation.test.ts @@ -0,0 +1,89 @@ +import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; + +test.describe("Page navigation", () => { + test("create a new page via page picker", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + + // Open page picker + await sbPage.keyboard.press(`${mod}+k`); + await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); + + // Type the new page name (with delay to avoid dropped keystrokes) + const pickerInput = sbPage.locator(".sb-modal-box .cm-content"); + await pickerInput.click(); + await sbPage.keyboard.type("My New Page", { delay: 30 }); + + // Should see "Create page" hint in the list + await expect(sbPage.locator(".sb-option .sb-hint", { hasText: "Create page" })).toBeVisible(); + + // Shift+Enter to create new page + await sbPage.keyboard.press("Shift+Enter"); + + // Modal should close + await expect(sbPage.locator(".sb-modal-box")).not.toBeVisible(); + + // Page name should update in top bar + await expect(sbPage.locator("#sb-current-page")).toContainText("My New Page"); + + // Editor should be empty (new page) + await expect(editor).toHaveText(""); + + // Type content and verify it saves to server + await editor.click(); + await sbPage.keyboard.type("New page content"); + await expect(editor).toContainText("New page content"); + + const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "My New Page.md"); + expect(content).toContain("New page content"); + }); + + test("navigate back to index via page picker", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + + // First, create and navigate to a new page + await sbPage.keyboard.press(`${mod}+k`); + const pickerInput = sbPage.locator(".sb-modal-box .cm-content"); + await pickerInput.click(); + await sbPage.keyboard.type("Temporary Page", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(sbPage.locator("#sb-current-page")).toContainText("Temporary Page"); + + // Now navigate back to index + await sbPage.keyboard.press(`${mod}+k`); + await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); + const pickerInput2 = sbPage.locator(".sb-modal-box .cm-content"); + await pickerInput2.click(); + await sbPage.keyboard.type("index", { delay: 30 }); + await sbPage.keyboard.press("Enter"); + + // Should be back on the index/welcome page + await expect(sbPage.locator("#sb-current-page")).toContainText("index"); + await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + }); + + test("create a page in a subfolder and verify on server", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + + // Open page picker and create a page with folder path + await sbPage.keyboard.press(`${mod}+k`); + const pickerInput = sbPage.locator(".sb-modal-box .cm-content"); + await pickerInput.click(); + await sbPage.keyboard.type("Notes/My Subfolder Page", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + + // Page name should show the full path + await expect(sbPage.locator("#sb-current-page")).toContainText("Notes/My Subfolder Page"); + await expect(editor).toHaveText(""); + + // Type something and verify it saves to server + await editor.click(); + await sbPage.keyboard.type("Content in a subfolder page"); + await expect(editor).toContainText("Content in a subfolder page"); + + const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Notes/My Subfolder Page.md"); + expect(content).toContain("Content in a subfolder page"); + }); +}); diff --git a/e2e/tasks.test.ts b/e2e/tasks.test.ts new file mode 100644 index 00000000..9ad325e2 --- /dev/null +++ b/e2e/tasks.test.ts @@ -0,0 +1,52 @@ +import { expect, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; + +test.describe("Task management", () => { + test.use({ + spaceFiles: { + "Tasks.md": "# My Tasks\n* [ ] Buy groceries\n* [ ] Write tests\n* [x] Already done", + }, + }); + + test("task checkboxes render", async ({ sbServer, page }) => { + await page.goto(`${sbServer.url}/Tasks?enableSW=0`); + await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); + const editor = page.locator("#sb-editor .cm-content"); + + await expect(page.locator("#sb-current-page")).toContainText("Tasks"); + await expect(editor).toContainText("Buy groceries"); + + // Task checkboxes render as + const checkboxes = page.locator(".sb-checkbox input[type='checkbox']"); + await expect(checkboxes.first()).toBeVisible({ timeout: 10_000 }); + await expect(checkboxes).toHaveCount(3); + + // Third checkbox ("Already done") should be checked + await expect(checkboxes.nth(2)).toBeChecked(); + // First two should be unchecked + await expect(checkboxes.nth(0)).not.toBeChecked(); + await expect(checkboxes.nth(1)).not.toBeChecked(); + }); + + test("toggle task state saves to server", async ({ sbServer, page }) => { + await page.goto(`${sbServer.url}/Tasks?enableSW=0`); + await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); + const editor = page.locator("#sb-editor .cm-content"); + + await expect(editor).toContainText("Buy groceries"); + + // Wait for checkboxes to render + const firstCheckbox = page.locator(".sb-checkbox input[type='checkbox']").first(); + await expect(firstCheckbox).toBeVisible({ timeout: 10_000 }); + await expect(firstCheckbox).not.toBeChecked(); + + // Click the first checkbox to toggle it + await firstCheckbox.click(); + + // Should now be checked + await expect(firstCheckbox).toBeChecked(); + + // Verify the change is saved to the server + const content = await waitForSaveAndReadFromServer(page, sbServer, "Tasks.md"); + expect(content).toMatch(/\* \[x\] Buy groceries/); + }); +}); diff --git a/e2e/wiki-links.test.ts b/e2e/wiki-links.test.ts new file mode 100644 index 00000000..f961d910 --- /dev/null +++ b/e2e/wiki-links.test.ts @@ -0,0 +1,60 @@ +import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; + +test.describe("Wiki links", () => { + test.describe("cross-page navigation", () => { + test.use({ + spaceFiles: { + "PageA.md": "# Page A\nLink to [[PageB]] here", + "PageB.md": "# Page B\nThis is page B content", + }, + }); + + test("navigate between pages via wiki link click", async ({ sbServer, page }) => { + await page.goto(`${sbServer.url}/PageA?enableSW=0`); + await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); + const editor = page.locator("#sb-editor .cm-content"); + + await expect(page.locator("#sb-current-page")).toContainText("PageA"); + await expect(editor).toContainText("Page A"); + + // Click on the "PageB" text inside the wiki link + const wikiLinkText = editor.locator(".sb-wiki-link", { hasText: "PageB" }); + await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); + await wikiLinkText.click(); + + // Should navigate to PageB + await expect(page.locator("#sb-current-page")).toContainText("PageB"); + await expect(editor).toContainText("Page B"); + }); + }); + + test("wiki link to non-existent page creates it on click and saves", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); + + // Navigate to a fresh page + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.keyboard.type("Link Source", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); + + // Type a wiki link to a page that doesn't exist + await editor.click(); + await sbPage.keyboard.type("Check out [[Brand New Page]]", { delay: 20 }); + + // Verify the source page with the wiki link is saved to server + const sourceContent = await waitForSaveAndReadFromServer(sbPage, sbServer, "Link Source.md"); + expect(sourceContent).toContain("[[Brand New Page]]"); + + // Move cursor away from the link so it renders + await sbPage.keyboard.press("Home"); + + // Click on the wiki link text to navigate/create + const wikiLinkText = editor.locator(".sb-wiki-link", { hasText: "Brand New Page" }); + await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); + await wikiLinkText.click(); + + await expect(sbPage.locator("#sb-current-page")).toContainText("Brand New Page"); + }); +}); diff --git a/package-lock.json b/package-lock.json index 32aec99d..a7aa637d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@biomejs/biome": "2.4.6", + "@playwright/test": "^1.58.2", "@preact/preset-vite": "^2.10.3", "@types/gitignore-parser": "^0.0.3", "@types/js-yaml": "^4.0.9", @@ -1596,6 +1597,21 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@preact/preset-vite": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.3.tgz", @@ -3053,6 +3069,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 0dc1597e..ab0a3a3f 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,9 @@ "lint": "biome lint .", "fmt": "biome format --write .", "fmt:check": "biome format .", - "bench": "vitest bench" + "bench": "vitest bench", + "test:e2e": "npx playwright test", + "test:e2e:headed": "npx playwright test --headed" }, "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -110,11 +112,11 @@ }, "devDependencies": { "@biomejs/biome": "2.4.6", + "@playwright/test": "^1.58.2", "@preact/preset-vite": "^2.10.3", "@types/gitignore-parser": "^0.0.3", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", - "@types/turndown": "^5.0.6", "fake-indexeddb": "6.0.1", "tsx": "^4.19.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..b98417df --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + expect: { timeout: 30_000 }, + fullyParallel: false, + workers: 1, + reporter: "list", + use: { + ...devices["Desktop Chrome"], + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/vitest.config.ts b/vitest.config.ts index 66a6cd1d..84f603b6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ "**/.opencode/**", "dist", "client_bundle", + "e2e", "website", "server", // Exclude helper test files (no test() blocks, only export helper functions)