diff --git a/Makefile b/Makefile index 0ec69ba..c9af2a8 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ RELEASE_FLAGS := -trimpath -ldflags="-s -w" EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js 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 # 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 ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh 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 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..3e3209e --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.tmp/ +test-results/ +playwright-report/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..739a89c --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..e8cc91d --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "datascape-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..10a03de --- /dev/null +++ b/e2e/playwright.config.js @@ -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", + }, +}); diff --git a/e2e/server.mjs b/e2e/server.mjs new file mode 100644 index 0000000..fe4c4d0 --- /dev/null +++ b/e2e/server.mjs @@ -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()); diff --git a/e2e/tests/pages.spec.js b/e2e/tests/pages.spec.js new file mode 100644 index 0000000..c4ee55d --- /dev/null +++ b/e2e/tests/pages.spec.js @@ -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); +});