Initial set of PlayWright e2e tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -10,6 +10,8 @@ silverbullet
|
||||
deploy.json
|
||||
*.generated
|
||||
tmp_playground
|
||||
test-results
|
||||
playwright-report
|
||||
/public_version.ts
|
||||
website/_plug
|
||||
tmp
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Generated
+60
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user