Add tests
This commit is contained in:
@@ -11,7 +11,7 @@ RELEASE_FLAGS := -trimpath -ldflags="-s -w"
|
|||||||
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
|
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
|
||||||
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
|
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
|
||||||
|
|
||||||
.PHONY: deploy companion companion-windows companion-linux companion-release editor
|
.PHONY: deploy companion companion-windows companion-linux companion-release editor e2e
|
||||||
|
|
||||||
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist
|
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist
|
||||||
# before `go build .` so embed.FS picks them up.
|
# before `go build .` so embed.FS picks them up.
|
||||||
@@ -46,3 +46,8 @@ deploy: companion-release
|
|||||||
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
||||||
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
|
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
|
||||||
rm datascape-arm
|
rm datascape-arm
|
||||||
|
|
||||||
|
# Browser end-to-end tests (Playwright driving the system Chrome). Builds the
|
||||||
|
# wiki binary and runs it against a throwaway wiki in e2e/.tmp.
|
||||||
|
e2e:
|
||||||
|
cd e2e && npm install --no-audit --no-fund && npx playwright test
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.tmp/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
Generated
+58
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "datascape-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "datascape-e2e",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "datascape-e2e",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
const port = process.env.E2E_PORT || "8765";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "tests",
|
||||||
|
// One seeded wiki is shared, and tests mutate it: run serially.
|
||||||
|
workers: 1,
|
||||||
|
fullyParallel: false,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
|
// System Chrome; no Playwright browser download needed.
|
||||||
|
channel: "chrome",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "node server.mjs",
|
||||||
|
url: `http://127.0.0.1:${port}/`,
|
||||||
|
timeout: 120_000,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
stdout: "pipe",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Builds datascape, seeds a throwaway wiki and runs the server in the
|
||||||
|
// foreground. Playwright's webServer starts this and kills it after the run.
|
||||||
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const tmp = join(here, ".tmp");
|
||||||
|
const wiki = join(tmp, "wiki");
|
||||||
|
const bin = join(tmp, process.platform === "win32" ? "datascape.exe" : "datascape");
|
||||||
|
const port = process.env.E2E_PORT || "8765";
|
||||||
|
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
mkdirSync(wiki, { recursive: true });
|
||||||
|
|
||||||
|
execFileSync("go", ["build", "-o", bin, "."], { cwd: join(here, ".."), stdio: "inherit" });
|
||||||
|
|
||||||
|
const pages = {
|
||||||
|
"index.md": "# Home\n\nWelcome.\n",
|
||||||
|
"Projects/index.md": "# Projects\n\n## Todo\n\n- [ ] first task\n- [x] second task\n",
|
||||||
|
"Projects/Alpha/index.md": "# Alpha\n\nAlpha page.\n",
|
||||||
|
"Projects/Beta/index.md": "# Beta\n\nBeta page.\n",
|
||||||
|
"Notes/index.md": "# Notes\n\nSome notes.\n",
|
||||||
|
};
|
||||||
|
for (const [p, body] of Object.entries(pages)) {
|
||||||
|
mkdirSync(dirname(join(wiki, p)), { recursive: true });
|
||||||
|
writeFileSync(join(wiki, p), body);
|
||||||
|
}
|
||||||
|
writeFileSync(join(wiki, "Projects", "readme.txt"), "a file\n");
|
||||||
|
|
||||||
|
const srv = spawn(bin, ["-dir", wiki, "-cache", join(tmp, "cache"), "-addr", "127.0.0.1:" + port, "-calendars", ""], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
srv.on("exit", (code) => process.exit(code ?? 0));
|
||||||
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => srv.kill());
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Baseline end-to-end coverage of the page UI. Tests drive the app through
|
||||||
|
// visible labels, not implementation details, so they keep passing when the
|
||||||
|
// client-side mechanics change (e.g. the move to HTMX).
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { mkdirSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
|
||||||
|
|
||||||
|
// Fail any test that logs a JS error.
|
||||||
|
test.beforeEach(({ page }) => {
|
||||||
|
page.on("pageerror", (err) => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidebar = (page) => page.locator("aside.tree-sidebar");
|
||||||
|
const modal = (page) => page.locator(".modal");
|
||||||
|
|
||||||
|
// Page actions sit in a dropdown on narrow screens and inline (icon-only) on
|
||||||
|
// wide ones; address them by title and open the dropdown only when present.
|
||||||
|
async function pageAction(page, title) {
|
||||||
|
const toggle = page.getByRole("button", { name: "ACTIONS" });
|
||||||
|
if (await toggle.isVisible()) await toggle.click();
|
||||||
|
await page.locator("header").getByTitle(title).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders a page and navigates via the sidebar", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("main h1")).toContainText("Home");
|
||||||
|
await sidebar(page).getByRole("link", { name: "Projects" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/Projects\/$/);
|
||||||
|
await expect(page.locator("main h1")).toContainText("Projects");
|
||||||
|
// The sidebar expands the active folder's children.
|
||||||
|
await expect(sidebar(page).getByRole("link", { name: "Alpha" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("task checkbox toggle persists", async ({ page }) => {
|
||||||
|
await page.goto("/Projects/");
|
||||||
|
const box = page.locator("main input.task-checkbox").first();
|
||||||
|
await expect(box).not.toBeChecked();
|
||||||
|
await box.check();
|
||||||
|
await expect(box).toBeEnabled();
|
||||||
|
await expect.poll(() => readFileSync(join(wiki, "Projects", "index.md"), "utf8")).toContain("- [x] first task");
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("view settings switch the listing to thumbnails", async ({ page }) => {
|
||||||
|
await page.goto("/Projects/");
|
||||||
|
await expect(page.locator("main table.data-table")).toBeVisible();
|
||||||
|
await page.getByTitle("View & sorting").click();
|
||||||
|
await modal(page).getByRole("button", { name: "Thumbnail" }).click();
|
||||||
|
await modal(page).getByRole("button", { name: "SAVE" }).click();
|
||||||
|
await expect(page.locator("main .thumb-grid")).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/Projects\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editor saves a page", async ({ page }) => {
|
||||||
|
await page.goto("/Projects/Alpha/");
|
||||||
|
await pageAction(page, "Edit page (E)");
|
||||||
|
await expect(page).toHaveURL(/\?edit/);
|
||||||
|
const editor = page.locator(".cm-content");
|
||||||
|
await editor.click();
|
||||||
|
await page.keyboard.press("Control+End");
|
||||||
|
await page.keyboard.type("\nAdded by e2e.\n");
|
||||||
|
await page.getByRole("button", { name: "SAVE" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/Projects\/Alpha\/$/);
|
||||||
|
await expect(page.locator("main")).toContainText("Added by e2e.");
|
||||||
|
// Save replaces the editor's history entry: Back leaves the page entirely.
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).not.toHaveURL(/\?edit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("move renames a page", async ({ page }) => {
|
||||||
|
await page.goto("/Projects/Beta/");
|
||||||
|
await pageAction(page, "Move page (M)");
|
||||||
|
// Tree picker preselects the current parent; keep it.
|
||||||
|
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||||
|
await modal(page).locator("input").fill("Gamma");
|
||||||
|
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/Projects\/Gamma\/$/);
|
||||||
|
await expect(page.locator("main h1")).toContainText("Beta");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("move onto an empty folder offers a merge", async ({ page }) => {
|
||||||
|
mkdirSync(join(wiki, "Projects", "Empty"), { recursive: true });
|
||||||
|
await page.goto("/Projects/Alpha/");
|
||||||
|
await pageAction(page, "Move page (M)");
|
||||||
|
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||||
|
await modal(page).locator("input").fill("Empty");
|
||||||
|
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||||
|
await expect(modal(page)).toContainText("Merge folders?");
|
||||||
|
await modal(page).getByRole("button", { name: "MERGE" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/Projects\/Empty\/$/);
|
||||||
|
await expect(page.locator("main h1")).toContainText("Alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete removes a page and returns to the parent", async ({ page }) => {
|
||||||
|
await page.goto("/Notes/");
|
||||||
|
await pageAction(page, "Delete page");
|
||||||
|
await expect(modal(page)).toContainText("Delete /Notes/");
|
||||||
|
await modal(page).getByRole("button", { name: "DELETE" }).click();
|
||||||
|
await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/$/);
|
||||||
|
await expect(sidebar(page).getByRole("link", { name: "Notes" })).toHaveCount(0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user