Initial set of PlayWright e2e tests

This commit is contained in:
Zef Hemel
2026-03-24 10:47:01 +01:00
parent 203ef99fc7
commit dc3c23b68f
14 changed files with 641 additions and 3 deletions
+37
View File
@@ -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
+2
View File
@@ -10,6 +10,8 @@ silverbullet
deploy.json
*.generated
tmp_playground
test-results
playwright-report
/public_version.ts
website/_plug
tmp
+4 -1
View File
@@ -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
+55
View File
@@ -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();
});
});
+87
View File
@@ -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");
});
});
+29
View File
@@ -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");
});
});
+139
View File
@@ -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<string, string>;
sbServer: SBServer;
sbPage: Page;
sbPageWithSync: Page;
};
async function getFreePort(): Promise<number> {
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<void> {
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<SBFixtures>({
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<void>((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<string> {
// 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";
+89
View File
@@ -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");
});
});
+52
View File
@@ -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 <span class="sb-checkbox"><input type="checkbox"></span>
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/);
});
});
+60
View File
@@ -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");
});
});
+60
View File
@@ -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",
+4 -2
View File
@@ -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",
+22
View File
@@ -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"] },
},
],
});
+1
View File
@@ -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)