Skip to content

Commit dc3c23b

Browse files
committed
Initial set of PlayWright e2e tests
1 parent 203ef99 commit dc3c23b

14 files changed

+641
-3
lines changed

.github/workflows/test.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,40 @@ jobs:
6666

6767
- name: Display test coverage
6868
run: go tool cover -func=coverage.out
69+
70+
test-e2e:
71+
runs-on: ubuntu-latest
72+
73+
steps:
74+
- name: Setup repo
75+
uses: actions/checkout@v3
76+
77+
- name: Setup Node.js
78+
uses: actions/setup-node@v4
79+
with:
80+
node-version-file: '.nvmrc'
81+
82+
- name: Setup Go
83+
uses: actions/setup-go@v5
84+
with:
85+
go-version: "1.25"
86+
87+
- name: Install dependencies
88+
run: npm ci
89+
90+
- name: Build
91+
run: make build
92+
93+
- name: Install Playwright browsers
94+
run: npx playwright install chromium
95+
96+
- name: Run e2e tests
97+
run: npx playwright test
98+
99+
- name: Upload test results
100+
if: ${{ !cancelled() }}
101+
uses: actions/upload-artifact@v4
102+
with:
103+
name: playwright-report
104+
path: test-results/
105+
retention-days: 7

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ silverbullet
1010
deploy.json
1111
*.generated
1212
tmp_playground
13+
test-results
14+
playwright-report
1315
/public_version.ts
1416
website/_plug
1517
tmp

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
LDFLAGS = -X main.buildTime=$$(date -u +%Y-%m-%dT%H:%M:%SZ)
22

3-
.PHONY: build build-for-docker docker build-server-releases clean check fmt test test-integration bench generate website
3+
.PHONY: build build-for-docker docker build-server-releases clean check fmt test test-integration test-e2e bench generate website
44

55
build:
66
# Build client
@@ -55,6 +55,9 @@ test-integration:
5555
# Run headless Chrome integration tests (requires Chrome installed)
5656
go test -tags=integration ./server/... -v -timeout 300s
5757

58+
test-e2e: build
59+
npx playwright test
60+
5861
bench:
5962
# Run frontend benchmarks
6063
npm run bench

e2e/command-palette.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expect, mod, test } from "./fixtures.ts";
2+
3+
test.describe("Command palette", () => {
4+
test("open and close command palette", async ({ sbPage }) => {
5+
const editor = sbPage.locator("#sb-editor .cm-content");
6+
await expect(editor).toContainText("Welcome");
7+
8+
// Open command palette
9+
await sbPage.keyboard.press(`${mod}+/`);
10+
const modal = sbPage.locator(".sb-modal-box");
11+
await expect(modal).toBeVisible();
12+
13+
// Close with Escape
14+
await sbPage.keyboard.press("Escape");
15+
await expect(modal).not.toBeVisible();
16+
});
17+
18+
test("filter commands by typing", async ({ sbPage }) => {
19+
const editor = sbPage.locator("#sb-editor .cm-content");
20+
await expect(editor).toContainText("Welcome");
21+
22+
// Open command palette
23+
await sbPage.keyboard.press(`${mod}+/`);
24+
const modal = sbPage.locator(".sb-modal-box");
25+
await expect(modal).toBeVisible();
26+
27+
// Type to filter
28+
const paletteInput = modal.locator(".cm-content");
29+
await paletteInput.click();
30+
await sbPage.keyboard.type("Stats", { delay: 30 });
31+
32+
// Should show filtered results including "Stats: Show"
33+
await expect(modal.locator(".sb-option .sb-name", { hasText: "Stats" })).toBeVisible();
34+
35+
await sbPage.keyboard.press("Escape");
36+
});
37+
38+
test("run a command from the palette", async ({ sbPage }) => {
39+
const editor = sbPage.locator("#sb-editor .cm-content");
40+
await expect(editor).toContainText("Welcome");
41+
42+
// Open command palette and run "Stats: Show"
43+
await sbPage.keyboard.press(`${mod}+/`);
44+
const modal = sbPage.locator(".sb-modal-box");
45+
const paletteInput = modal.locator(".cm-content");
46+
await paletteInput.click();
47+
await sbPage.keyboard.type("Stats: Show", { delay: 30 });
48+
49+
// Select the matching command
50+
await sbPage.keyboard.press("Enter");
51+
52+
// The modal should close
53+
await expect(modal).not.toBeVisible();
54+
});
55+
});

e2e/editor-formatting.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts";
2+
3+
test.describe("Editor formatting", () => {
4+
test("bold text with Mod+B", async ({ sbPage, sbServer }) => {
5+
const editor = sbPage.locator("#sb-editor .cm-content");
6+
await expect(editor).toContainText("Welcome");
7+
8+
// Navigate to a fresh page
9+
await sbPage.keyboard.press(`${mod}+k`);
10+
await sbPage.locator(".sb-modal-box .cm-content").click();
11+
await sbPage.keyboard.type("Formatting Test", { delay: 30 });
12+
await sbPage.keyboard.press("Shift+Enter");
13+
await expect(editor).toHaveText("");
14+
15+
// Type some text
16+
await editor.click();
17+
await sbPage.keyboard.type("make this bold");
18+
19+
// Select all
20+
await sbPage.keyboard.press(`${mod}+a`);
21+
22+
// Apply bold
23+
await sbPage.keyboard.press(`${mod}+b`);
24+
25+
// Verify bold markers appear
26+
await expect(editor).toContainText("**make this bold**");
27+
28+
// Verify saved to server
29+
const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Formatting Test.md");
30+
expect(content).toContain("**make this bold**");
31+
});
32+
33+
test("italic text with Mod+I", async ({ sbPage, sbServer }) => {
34+
const editor = sbPage.locator("#sb-editor .cm-content");
35+
await expect(editor).toContainText("Welcome");
36+
37+
await sbPage.keyboard.press(`${mod}+k`);
38+
await sbPage.locator(".sb-modal-box .cm-content").click();
39+
await sbPage.keyboard.type("Italic Test", { delay: 30 });
40+
await sbPage.keyboard.press("Shift+Enter");
41+
await expect(editor).toHaveText("");
42+
43+
await editor.click();
44+
await sbPage.keyboard.type("make this italic");
45+
await sbPage.keyboard.press(`${mod}+a`);
46+
await sbPage.keyboard.press(`${mod}+i`);
47+
48+
await expect(editor).toContainText("_make this italic_");
49+
50+
const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Italic Test.md");
51+
expect(content).toContain("_make this italic_");
52+
});
53+
54+
test("bullet list with Mod+Shift+8", async ({ sbPage, sbServer }) => {
55+
const editor = sbPage.locator("#sb-editor .cm-content");
56+
await expect(editor).toContainText("Welcome");
57+
58+
await sbPage.keyboard.press(`${mod}+k`);
59+
await sbPage.locator(".sb-modal-box .cm-content").click();
60+
await sbPage.keyboard.type("List Test", { delay: 30 });
61+
await sbPage.keyboard.press("Shift+Enter");
62+
await expect(editor).toHaveText("");
63+
64+
// Type multiple lines
65+
await editor.click();
66+
await sbPage.keyboard.type("First item");
67+
await sbPage.keyboard.press("Enter");
68+
await sbPage.keyboard.type("Second item");
69+
await sbPage.keyboard.press("Enter");
70+
await sbPage.keyboard.type("Third item");
71+
72+
// Select all and make it a bullet list
73+
await sbPage.keyboard.press(`${mod}+a`);
74+
await sbPage.keyboard.press(`${mod}+Shift+8`);
75+
76+
// Verify bullet markers
77+
await expect(editor).toContainText("* First item");
78+
await expect(editor).toContainText("* Second item");
79+
await expect(editor).toContainText("* Third item");
80+
81+
// Verify saved to server
82+
const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "List Test.md");
83+
expect(content).toContain("* First item");
84+
expect(content).toContain("* Second item");
85+
expect(content).toContain("* Third item");
86+
});
87+
});

e2e/first-load.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { expect, test, waitForSaveAndReadFromServer } from "./fixtures.ts";
2+
3+
test.describe("First load on empty space", () => {
4+
test("page loads and shows editor", async ({ sbPage }) => {
5+
await expect(sbPage.locator("#sb-editor")).toBeVisible();
6+
await expect(sbPage.locator("#sb-editor .cm-editor")).toBeVisible();
7+
});
8+
9+
test("welcome page content appears", async ({ sbPage }) => {
10+
const editor = sbPage.locator("#sb-editor .cm-content");
11+
await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet");
12+
});
13+
14+
test("editor is editable and saves to server", async ({ sbPage, sbServer }) => {
15+
const editor = sbPage.locator("#sb-editor .cm-content");
16+
await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet");
17+
18+
// Move to end of document, then type on a new line
19+
await editor.click();
20+
await sbPage.keyboard.press("End");
21+
await sbPage.keyboard.press("Enter");
22+
await sbPage.keyboard.type("Hello from Playwright");
23+
await expect(editor).toContainText("Hello from Playwright");
24+
25+
// Verify the edit was saved to the server
26+
const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "index.md");
27+
expect(content).toContain("Hello from Playwright");
28+
});
29+
});

e2e/fixtures.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { test as base, type Page } from "@playwright/test";
2+
import { type ChildProcess, spawn } from "node:child_process";
3+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4+
import net from "node:net";
5+
import { platform, tmpdir } from "node:os";
6+
import { dirname, join } from "node:path";
7+
8+
/** The platform-appropriate modifier key: Meta on macOS, Control elsewhere. */
9+
export const mod = platform() === "darwin" ? "Meta" : "Control";
10+
11+
export type SBServer = {
12+
url: string;
13+
port: number;
14+
spaceDir: string;
15+
};
16+
17+
type SBFixtures = {
18+
spaceFiles: Record<string, string>;
19+
sbServer: SBServer;
20+
sbPage: Page;
21+
sbPageWithSync: Page;
22+
};
23+
24+
async function getFreePort(): Promise<number> {
25+
return new Promise((resolve, reject) => {
26+
const srv = net.createServer();
27+
srv.listen(0, "127.0.0.1", () => {
28+
const addr = srv.address() as net.AddressInfo;
29+
srv.close(() => resolve(addr.port));
30+
});
31+
srv.on("error", reject);
32+
});
33+
}
34+
35+
async function waitForServer(url: string, timeoutMs = 30_000): Promise<void> {
36+
const deadline = Date.now() + timeoutMs;
37+
while (Date.now() < deadline) {
38+
try {
39+
const resp = await fetch(url);
40+
if (resp.ok) return;
41+
} catch {
42+
// server not ready yet
43+
}
44+
await new Promise((r) => setTimeout(r, 200));
45+
}
46+
throw new Error(`Server did not become ready at ${url} within ${timeoutMs}ms`);
47+
}
48+
49+
export const test = base.extend<SBFixtures>({
50+
spaceFiles: [{}, { option: true }],
51+
52+
sbServer: async ({ spaceFiles }, use) => {
53+
const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-"));
54+
55+
// Seed space with files
56+
for (const [path, content] of Object.entries(spaceFiles)) {
57+
const fullPath = join(spaceDir, path);
58+
await mkdir(dirname(fullPath), { recursive: true });
59+
await writeFile(fullPath, content);
60+
}
61+
62+
const port = await getFreePort();
63+
64+
const proc: ChildProcess = spawn(
65+
"./silverbullet",
66+
[spaceDir, "-p", String(port), "-L", "127.0.0.1"],
67+
{
68+
cwd: join(import.meta.dirname, ".."),
69+
stdio: ["ignore", "pipe", "pipe"],
70+
},
71+
);
72+
73+
let serverOutput = "";
74+
proc.stdout?.on("data", (d: Buffer) => {
75+
serverOutput += d.toString();
76+
});
77+
proc.stderr?.on("data", (d: Buffer) => {
78+
serverOutput += d.toString();
79+
});
80+
81+
const url = `http://127.0.0.1:${port}`;
82+
83+
try {
84+
await waitForServer(`${url}/.ping`);
85+
} catch (err) {
86+
proc.kill("SIGKILL");
87+
throw new Error(`Server failed to start. Output:\n${serverOutput}\n${err}`);
88+
}
89+
90+
await use({ url, port, spaceDir });
91+
92+
// Cleanup
93+
proc.kill("SIGTERM");
94+
await new Promise<void>((resolve) => {
95+
const timer = setTimeout(() => {
96+
proc.kill("SIGKILL");
97+
resolve();
98+
}, 5000);
99+
proc.on("exit", () => {
100+
clearTimeout(timer);
101+
resolve();
102+
});
103+
});
104+
await rm(spaceDir, { recursive: true, force: true });
105+
},
106+
107+
sbPage: async ({ sbServer, page }, use) => {
108+
await page.goto(`${sbServer.url}/?enableSW=0`);
109+
await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 });
110+
await use(page);
111+
},
112+
113+
sbPageWithSync: async ({ sbServer, page }, use) => {
114+
await page.goto(sbServer.url);
115+
await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 });
116+
await use(page);
117+
},
118+
});
119+
120+
/**
121+
* Wait for the client to save (indicated by #sb-current-page having class "sb-saved"),
122+
* then fetch the page content from the server's filesystem API.
123+
*/
124+
export async function waitForSaveAndReadFromServer(
125+
page: Page,
126+
sbServer: SBServer,
127+
pagePath: string,
128+
): Promise<string> {
129+
// Wait for the save indicator — "sb-saved" class on the page name element
130+
await page.locator("#sb-current-page.sb-saved").waitFor({ state: "attached", timeout: 10_000 });
131+
// Fetch the file from the server
132+
const resp = await fetch(`${sbServer.url}/.fs/${pagePath}`);
133+
if (!resp.ok) {
134+
throw new Error(`Failed to read ${pagePath} from server: ${resp.status}`);
135+
}
136+
return resp.text();
137+
}
138+
139+
export { expect } from "@playwright/test";

0 commit comments

Comments
 (0)