From 22dacfa89099cbe1eab3e4d0737daaee125c5e99 Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Fri, 6 Mar 2026 21:48:40 +0100 Subject: [PATCH] add playwright skill API for token-efficient Alkalye automation --- bun.lock | 9 + e2e/auth-helpers.ts | 1 + e2e/auth.spec.ts | 23 ++ e2e/doc-helpers.ts | 1 + e2e/doc.spec.ts | 52 +++ e2e/document-collab-helpers.ts | 1 + e2e/document-collab.spec.ts | 59 ++++ e2e/space-helpers.ts | 1 + e2e/space.spec.ts | 73 ++++ package.json | 7 + playwright.config.ts | 32 ++ skills/alkalye-playwright-crud/SKILL.md | 99 ++++++ .../helpers/auth-helpers.ts | 171 +++++++++ .../helpers/doc-helpers.ts | 274 +++++++++++++++ .../helpers/document-collab-helpers.ts | 165 +++++++++ .../helpers/space-helpers.ts | 308 ++++++++++++++++ .../scripts/EXAMPLE.md | 39 +++ .../scripts/payload.example.json | 17 + .../scripts/run-task.ts | 330 ++++++++++++++++++ src/app/routes/doc.$id.index.tsx | 8 +- src/app/routes/invite.tsx | 17 +- src/app/routes/settings.tsx | 15 +- .../routes/spaces.$spaceId.doc.$id.index.tsx | 2 + src/app/routes/spaces.$spaceId.settings.tsx | 7 + src/components/auth-form.tsx | 56 ++- src/components/share-dialog.tsx | 10 +- src/components/sidebar-collaboration.tsx | 4 + src/components/sidebar-document-list.tsx | 28 +- src/components/sidebar-file-menu.tsx | 9 +- src/components/space-selector.tsx | 20 +- src/components/space-share-dialog.tsx | 10 +- src/components/ui/confirm-dialog.tsx | 11 +- src/lib/test-ids.ts | 115 ++++++ 33 files changed, 1950 insertions(+), 24 deletions(-) create mode 120000 e2e/auth-helpers.ts create mode 100644 e2e/auth.spec.ts create mode 120000 e2e/doc-helpers.ts create mode 100644 e2e/doc.spec.ts create mode 120000 e2e/document-collab-helpers.ts create mode 100644 e2e/document-collab.spec.ts create mode 120000 e2e/space-helpers.ts create mode 100644 e2e/space.spec.ts create mode 100644 playwright.config.ts create mode 100644 skills/alkalye-playwright-crud/SKILL.md create mode 100644 skills/alkalye-playwright-crud/helpers/auth-helpers.ts create mode 100644 skills/alkalye-playwright-crud/helpers/doc-helpers.ts create mode 100644 skills/alkalye-playwright-crud/helpers/document-collab-helpers.ts create mode 100644 skills/alkalye-playwright-crud/helpers/space-helpers.ts create mode 100644 skills/alkalye-playwright-crud/scripts/EXAMPLE.md create mode 100644 skills/alkalye-playwright-crud/scripts/payload.example.json create mode 100644 skills/alkalye-playwright-crud/scripts/run-task.ts create mode 100644 src/lib/test-ids.ts diff --git a/bun.lock b/bun.lock index e536e48..1210179 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "@astrojs/vercel": "^9.0.4", "@eslint/js": "^9.39.2", "@happy-dom/global-registrator": "^20.3.7", + "@playwright/test": "^1.58.2", "@types/dompurify": "^3.2.0", "@types/node": "^24.10.9", "@types/react": "^19.2.9", @@ -667,6 +668,8 @@ "@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], @@ -2057,6 +2060,10 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -2889,6 +2896,8 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/e2e/auth-helpers.ts b/e2e/auth-helpers.ts new file mode 120000 index 0000000..123c021 --- /dev/null +++ b/e2e/auth-helpers.ts @@ -0,0 +1 @@ +../skills/alkalye-playwright-crud/helpers/auth-helpers.ts \ No newline at end of file diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..252c031 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "@playwright/test" +import { + createAccount, + signIn, + signOut, + waitForEditorBoot, +} from "./auth-helpers" + +test("auth flow: create account, sign out, sign in", async ({ page }) => { + let boot = await waitForEditorBoot(page) + expect(boot.ok).toBe(true) + + let created = await createAccount(page) + expect(created.ok).toBe(true) + expect(created.signedIn).toBe(true) + expect(created.passphrase.trim().length).toBeGreaterThan(10) + + let signedOut = await signOut(page) + expect(signedOut).toEqual({ ok: true, signedIn: false }) + + let signedIn = await signIn(page, { passphrase: created.passphrase }) + expect(signedIn).toEqual({ ok: true, signedIn: true }) +}) diff --git a/e2e/doc-helpers.ts b/e2e/doc-helpers.ts new file mode 120000 index 0000000..85e12ea --- /dev/null +++ b/e2e/doc-helpers.ts @@ -0,0 +1 @@ +../skills/alkalye-playwright-crud/helpers/doc-helpers.ts \ No newline at end of file diff --git a/e2e/doc.spec.ts b/e2e/doc.spec.ts new file mode 100644 index 0000000..caf5b53 --- /dev/null +++ b/e2e/doc.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test" +import { waitForEditorBoot, createAccount } from "./auth-helpers" +import { create, readById, updateById, list, deleteById } from "./doc-helpers" + +test("document CRUD helpers return JSON", async ({ page }) => { + await waitForEditorBoot(page) + await createAccount(page) + + let before = await list(page) + expect(before.ok).toBe(true) + + let created = await create(page, { + title: "CRUD JSON Doc", + tags: ["e2e", "json"], + path: "tests", + body: "create body", + }) + expect(created.ok).toBe(true) + expect(created.id.length).toBeGreaterThan(10) + + let read = await readById(page, { id: created.id }) + expect(read.ok).toBe(true) + expect(read.document.id).toBe(created.id) + expect(read.document.title).toContain("CRUD JSON Doc") + + let updated = await updateById(page, { + id: created.id, + title: "CRUD JSON Doc Updated", + body: "updated body", + tags: ["e2e", "updated"], + path: "tests/updated", + }) + expect(updated.ok).toBe(true) + expect(updated.document.title).toContain("CRUD JSON Doc Updated") + expect(updated.document.content).toContain("updated body") + + let filtered = await list(page, { search: "CRUD JSON Doc Updated" }) + expect(filtered.ok).toBe(true) + expect(filtered.items.some(item => item.id === created.id)).toBe(true) + + let deleted = await deleteById(page, { id: created.id }) + expect(deleted).toEqual({ + ok: true, + id: created.id, + spaceId: null, + deleted: true, + }) + + let after = await list(page) + expect(after.ok).toBe(true) + expect(after.count).toBe(before.count) +}) diff --git a/e2e/document-collab-helpers.ts b/e2e/document-collab-helpers.ts new file mode 120000 index 0000000..afe9776 --- /dev/null +++ b/e2e/document-collab-helpers.ts @@ -0,0 +1 @@ +../skills/alkalye-playwright-crud/helpers/document-collab-helpers.ts \ No newline at end of file diff --git a/e2e/document-collab.spec.ts b/e2e/document-collab.spec.ts new file mode 100644 index 0000000..3a9053f --- /dev/null +++ b/e2e/document-collab.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test" +import { createAccount, waitForEditorBoot } from "./auth-helpers" +import { create } from "./doc-helpers" +import { + acceptDocumentInvite, + createDocumentInvite, + listDocumentInvites, + revokeDocumentInvite, +} from "./document-collab-helpers" + +test("document invite CRUD helpers return JSON", async ({ page }) => { + await waitForEditorBoot(page) + await createAccount(page) + + let created = await create(page, { + title: "Doc Invite CRUD", + body: "content", + }) + + let invite = await createDocumentInvite(page, { + docId: created.id, + role: "writer", + }) + expect(invite.ok).toBe(true) + + let pending = await listDocumentInvites(page, { + docId: created.id, + }) + expect(pending.ok).toBe(true) + expect( + pending.items.some(item => item.inviteGroupId === invite.inviteGroupId), + ).toBe(true) + + let revoked = await revokeDocumentInvite(page, { + docId: created.id, + inviteGroupId: invite.inviteGroupId ?? undefined, + }) + expect(revoked.ok).toBe(true) +}) + +test("document invite accept helper returns JSON", async ({ page }) => { + await waitForEditorBoot(page) + await createAccount(page) + + let created = await create(page, { + title: "Doc Invite Accept", + body: "acceptance", + }) + + let invite = await createDocumentInvite(page, { + docId: created.id, + role: "reader", + }) + + let accepted = await acceptDocumentInvite(page, { link: invite.link }) + expect(accepted.ok).toBe(true) + expect(accepted.docId).toBe(created.id) + expect(accepted.url).toContain(`/app/doc/${created.id}`) +}) diff --git a/e2e/space-helpers.ts b/e2e/space-helpers.ts new file mode 120000 index 0000000..d059f29 --- /dev/null +++ b/e2e/space-helpers.ts @@ -0,0 +1 @@ +../skills/alkalye-playwright-crud/helpers/space-helpers.ts \ No newline at end of file diff --git a/e2e/space.spec.ts b/e2e/space.spec.ts new file mode 100644 index 0000000..d49ce34 --- /dev/null +++ b/e2e/space.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test" +import { createAccount, waitForEditorBoot } from "./auth-helpers" +import { + acceptSpaceInvite, + createSpace, + createSpaceInvite, + deleteSpaceById, + listSpaceInvites, + listSpaces, + readSpaceById, + revokeSpaceInvite, + updateSpaceById, +} from "./space-helpers" + +test("space CRUD + invite helpers return JSON", async ({ page }) => { + await waitForEditorBoot(page) + await createAccount(page) + + let created = await createSpace(page, { name: "E2E Space" }) + expect(created.ok).toBe(true) + + let listed = await listSpaces(page) + expect(listed.ok).toBe(true) + expect(listed.items.some(space => space.id === created.id)).toBe(true) + + let read = await readSpaceById(page, { spaceId: created.id }) + expect(read.ok).toBe(true) + expect(read.space.name).toBe("E2E Space") + + let updated = await updateSpaceById(page, { + spaceId: created.id, + name: "E2E Space Updated", + }) + expect(updated.ok).toBe(true) + + let invite = await createSpaceInvite(page, { + spaceId: created.id, + role: "reader", + }) + expect(invite.ok).toBe(true) + expect(invite.link).toContain("invite") + + let pending = await listSpaceInvites(page, { spaceId: created.id }) + expect(pending.ok).toBe(true) + expect( + pending.items.some(i => i.inviteGroupId === invite.inviteGroupId), + ).toBe(true) + + let revoked = await revokeSpaceInvite(page, { + spaceId: created.id, + inviteGroupId: invite.inviteGroupId ?? undefined, + }) + expect(revoked.ok).toBe(true) + + let removed = await deleteSpaceById(page, { spaceId: created.id }) + expect(removed.ok).toBe(true) +}) + +test("space invite accept helper returns JSON", async ({ page }) => { + await waitForEditorBoot(page) + await createAccount(page) + + let created = await createSpace(page, { name: "Invite Accept Space" }) + let invite = await createSpaceInvite(page, { + spaceId: created.id, + role: "reader", + }) + + let accepted = await acceptSpaceInvite(page, { link: invite.link }) + expect(accepted.ok).toBe(true) + expect(accepted.spaceId).toBe(created.id) + expect(accepted.url).toContain(`/app/spaces/${created.id}`) +}) diff --git a/package.json b/package.json index 051bfc7..44b9bd1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,12 @@ "check:types": "astro check", "check:format": "prettier --check .", "check:test": "bun test src/editor src/lib/backup src/lib/documents src/lib/export src/lib/import src/lib/presentation src/lib/spaces && bun vitest run src/lib/theme-sanitize.test.ts", + "test:e2e": "playwright test", + "test:e2e:auth": "playwright test e2e/auth.spec.ts", + "test:e2e:spaces": "playwright test e2e/space.spec.ts", + "test:e2e:doc-collab": "playwright test e2e/document-collab.spec.ts", + "test:e2e:doc": "playwright test e2e/doc.spec.ts", + "test:e2e:headed": "playwright test --headed", "preview": "astro preview", "format": "prettier --write .", "test": "vitest" @@ -78,6 +84,7 @@ "@astrojs/vercel": "^9.0.4", "@eslint/js": "^9.39.2", "@happy-dom/global-registrator": "^20.3.7", + "@playwright/test": "^1.58.2", "@types/dompurify": "^3.2.0", "@types/node": "^24.10.9", "@types/react": "^19.2.9", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..27d16dc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test" + +let port = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? "4173", 10) +let baseUrl = `http://127.0.0.1:${port}` + +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: "list", + use: { + baseURL: baseUrl, + trace: "on-first-retry", + permissions: ["clipboard-read", "clipboard-write"], + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: `sh -c 'if ! lsof -iTCP:4200 -sTCP:LISTEN >/dev/null 2>&1; then bunx jazz-run sync --in-memory & fi; bun run dev --host 127.0.0.1 --port ${port}'`, + url: `${baseUrl}/app`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/skills/alkalye-playwright-crud/SKILL.md b/skills/alkalye-playwright-crud/SKILL.md new file mode 100644 index 0000000..6826b1a --- /dev/null +++ b/skills/alkalye-playwright-crud/SKILL.md @@ -0,0 +1,99 @@ +--- +name: alkalye-playwright-crud +description: Run stable Playwright CRUD automation for Alkalye auth, documents, spaces, and collaboration invites. Use when implementing or validating user flows, generating reusable automation, or debugging selector stability. +compatibility: Requires Bun, Playwright Chromium, and local app runtime. +metadata: + author: alkalye + version: "0.2" +--- + +Alkalye is an offline-capable, local-first markdown editor and collaboration app built with Astro, React, and Jazz sync. + +Use this skill to drive UI flows via stable `data-testid` selectors and JSON-returning helper functions. + +Primary purpose: let the agent complete Alkalye tasks without manual UI clicking by writing and running one Playwright script that calls these helpers. + +Helpers live in `helpers/` and are symlinked into `e2e/` for tests. + +## Execution Model + +- Prefer a single script execution for real tasks. +- Compose task scripts in `scripts/` using helper calls. +- Keep scripts deterministic and return JSON results. +- Use e2e specs as reference, but do not require interactive/manual steps. + +Example flow for agents: + +1. Create/choose one script file in `skills/alkalye-playwright-crud/scripts/`. +2. Import required helpers from `../helpers/*`. +3. Launch Playwright, run helper chain, print JSON. +4. Exit the browser context. + +Script template: `scripts/EXAMPLE.md` + +Runnable task runner: `scripts/run-task.ts` + +## Run Task Script + +Run with inline JSON: + +```bash +bun run skills/alkalye-playwright-crud/scripts/run-task.ts '{"steps":[{"task":"auth.waitForEditorBoot"},{"task":"auth.createAccount"},{"task":"doc.create","args":{"title":"Script Doc","body":"hello"}},{"task":"doc.list","args":{"search":"Script Doc"}}]}' +``` + +Run with payload file: + +```bash +bun run skills/alkalye-playwright-crud/scripts/run-task.ts --file skills/alkalye-playwright-crud/scripts/payload.json +``` + +## Helper Contract + +- Every helper receives `page` plus args. +- Every helper returns JSON. +- Never use text selectors when a `data-testid` exists. + +## Helper Files + +- `helpers/auth-helpers.ts` + - `waitForEditorBoot(page, { path? })` + - `openSettings(page, { fromPath? })` + - `createAccount(page, { openSettings? })` + - `signOut(page, { openSettings? })` + - `signIn(page, { passphrase, openSettings? })` + - `getRecoveryPhrase(page)` + +- `helpers/doc-helpers.ts` + - `create(page, { title?, body?, content?, tags?, path?, spaceId? })` + - `readById(page, { id, spaceId? })` + - `updateById(page, { id, title?, body?, content?, tags?, path?, spaceId? })` + - `list(page, { search?, spaceId? })` + - `deleteById(page, { id, spaceId? })` + +- `helpers/space-helpers.ts` + - `createSpace(page, { name })` + - `readSpaceById(page, { spaceId })` + - `updateSpaceById(page, { spaceId, name })` + - `listSpaces(page)` + - `deleteSpaceById(page, { spaceId })` + - `createSpaceInvite(page, { spaceId, role })` + - `listSpaceInvites(page, { spaceId })` + - `revokeSpaceInvite(page, { spaceId, inviteGroupId? })` + - `acceptSpaceInvite(page, { link })` + +- `helpers/document-collab-helpers.ts` + - `createDocumentInvite(page, { docId, spaceId?, role })` + - `listDocumentInvites(page, { docId, spaceId? })` + - `revokeDocumentInvite(page, { docId, spaceId?, inviteGroupId? })` + - `acceptDocumentInvite(page, { link })` + +## Validation Commands + +- `bun run test:e2e:auth` +- `bun run test:e2e:doc` +- `bun run test:e2e:spaces` +- `bun run test:e2e:doc-collab` + +## Selector Source + +- Selector contract: `src/lib/test-ids.ts` diff --git a/skills/alkalye-playwright-crud/helpers/auth-helpers.ts b/skills/alkalye-playwright-crud/helpers/auth-helpers.ts new file mode 100644 index 0000000..2bb2a52 --- /dev/null +++ b/skills/alkalye-playwright-crud/helpers/auth-helpers.ts @@ -0,0 +1,171 @@ +import { expect, type Page } from "@playwright/test" +import { testIds } from "@/lib/test-ids" + +export { + waitForEditorBoot, + openSettings, + createAccount, + signOut, + signIn, + getRecoveryPhrase, +} + +interface WaitForEditorBootArgs { + path?: string +} + +interface OpenSettingsArgs { + fromPath?: string +} + +interface CreateAccountArgs { + openSettings?: boolean +} + +interface SignInArgs { + passphrase: string + openSettings?: boolean +} + +interface SignOutArgs { + openSettings?: boolean +} + +async function waitForEditorBoot(page: Page, args: WaitForEditorBootArgs = {}) { + let path = args.path ?? "/app" + await page.goto(path) + await expect + .poll(async () => { + return page.evaluate(() => { + return document.body.getAttribute("data-alkalye-ready") + }) + }) + .toBe("true") + + let route = await page.evaluate(() => { + return ( + (window as { __alkalyeReadyRoute?: string }).__alkalyeReadyRoute ?? null + ) + }) + + return { + ok: true, + url: page.url(), + route, + } +} + +async function openSettings(page: Page, args: OpenSettingsArgs = {}) { + let fromPath = args.fromPath ?? "/app/settings" + await page.goto(fromPath) + await expect(page.getByText("Cloud Sync & Backup")).toBeVisible() + + return { + ok: true, + url: page.url(), + } +} + +async function createAccount(page: Page, args: CreateAccountArgs = {}) { + if (args.openSettings ?? true) { + await openSettings(page) + } + + await page.getByTestId(testIds.auth.settingsSignIn).click() + await page.getByTestId(testIds.auth.initialCreateAccount).click() + + let recovery = await getRecoveryPhrase(page) + + await page.getByTestId(testIds.auth.createCopy).click() + await page.getByTestId(testIds.auth.createSubmit).click() + + let dialog = page.getByTestId(testIds.auth.dialog) + let didClose = await expect(dialog) + .toBeHidden({ timeout: 5_000 }) + .then(() => true) + .catch(() => false) + + if (!didClose) { + let dialogText = await dialog.innerText() + throw new Error(`Create account dialog did not close: ${dialogText}`) + } + + let createError = page.getByTestId(testIds.auth.createError) + if (await createError.isVisible({ timeout: 2_000 }).catch(() => false)) { + throw new Error(await createError.innerText()) + } + + await openSettings(page) + await expect(page.getByTestId(testIds.auth.settingsSignOut)).toBeVisible({ + timeout: 30_000, + }) + + return { + ok: true, + signedIn: true, + passphrase: recovery.passphrase, + } +} + +async function signOut(page: Page, args: SignOutArgs = {}) { + if (args.openSettings ?? true) { + await openSettings(page) + } + + await page.getByTestId(testIds.auth.settingsSignOut).click() + await expect(page.getByTestId(testIds.auth.settingsSignIn)).toBeVisible() + + return { + ok: true, + signedIn: false, + } +} + +async function signIn(page: Page, args: SignInArgs) { + if (args.openSettings ?? true) { + await openSettings(page) + } + + await page.getByTestId(testIds.auth.settingsSignIn).click() + await page.getByTestId(testIds.auth.initialSignIn).click() + await page.getByTestId(testIds.auth.loginPassphrase).fill(args.passphrase) + await page.getByTestId(testIds.auth.loginSubmit).click() + + let dialog = page.getByTestId(testIds.auth.dialog) + let didClose = await expect(dialog) + .toBeHidden({ timeout: 10_000 }) + .then(() => true) + .catch(() => false) + if (!didClose) { + let dialogText = await dialog.innerText() + throw new Error(`Sign in dialog did not close: ${dialogText}`) + } + + let loginError = page.getByTestId(testIds.auth.loginError) + if (await loginError.isVisible({ timeout: 2_000 }).catch(() => false)) { + throw new Error(await loginError.innerText()) + } + + await openSettings(page) + await expect(page.getByTestId(testIds.auth.settingsSignOut)).toBeVisible({ + timeout: 30_000, + }) + + return { + ok: true, + signedIn: true, + } +} + +async function getRecoveryPhrase(page: Page) { + let passphrase = await page + .getByTestId(testIds.auth.createPassphrase) + .inputValue() + + expect(passphrase.trim().length).toBeGreaterThan(10) + + return { + ok: true, + passphrase, + } +} diff --git a/skills/alkalye-playwright-crud/helpers/doc-helpers.ts b/skills/alkalye-playwright-crud/helpers/doc-helpers.ts new file mode 100644 index 0000000..47d4c05 --- /dev/null +++ b/skills/alkalye-playwright-crud/helpers/doc-helpers.ts @@ -0,0 +1,274 @@ +import { expect, type Page } from "@playwright/test" +import { testIds } from "@/lib/test-ids" +import { waitForEditorBoot } from "./auth-helpers" + +export { create, readById, updateById, list, deleteById } + +interface CreateArgs { + spaceId?: string + title?: string + body?: string + content?: string + tags?: string[] + path?: string +} + +interface ReadByIdArgs { + id: string + spaceId?: string +} + +interface UpdateByIdArgs { + id: string + spaceId?: string + content?: string + title?: string + body?: string + tags?: string[] + path?: string +} + +interface ListArgs { + spaceId?: string + search?: string +} + +interface DeleteByIdArgs { + id: string + spaceId?: string +} + +async function create(page: Page, args: CreateArgs = {}) { + let query = args.spaceId ? `?spaceId=${encodeURIComponent(args.spaceId)}` : "" + await waitForEditorBoot(page, { path: `/app/new${query}` }) + + let id = getDocIdFromUrl(page.url()) + if (!id) { + throw new Error(`Could not parse doc id from URL: ${page.url()}`) + } + + let desiredContent = + args.content ?? + buildContent({ + title: args.title, + body: args.body, + tags: args.tags, + path: args.path, + }) + + let updated = await updateById(page, { + id, + spaceId: args.spaceId, + content: desiredContent, + }) + + return { + ok: true, + id, + spaceId: args.spaceId ?? null, + url: page.url(), + document: updated.document, + } +} + +async function readById(page: Page, args: ReadByIdArgs) { + let targetPath = getDocPath(args.id, args.spaceId) + await waitForEditorBoot(page, { path: targetPath }) + + let content = await getEditorContent(page) + let item = await readListItemById(page, args.id) + let fallbackTitle = inferTitleFromContent(content) + + let document = { + id: args.id, + title: item?.title ?? fallbackTitle, + tags: item?.tags ?? [], + path: item?.path ?? null, + date: item?.date ?? new Date().toISOString(), + content, + spaceId: args.spaceId ?? null, + } + + return { + ok: true, + url: page.url(), + document, + } +} + +async function updateById(page: Page, args: UpdateByIdArgs) { + let targetPath = getDocPath(args.id, args.spaceId) + await waitForEditorBoot(page, { path: targetPath }) + + let content = + args.content ?? + buildContent({ + title: args.title, + body: args.body, + tags: args.tags, + path: args.path, + }) + + await setEditorContent(page, content) + await page.waitForTimeout(250) + + let latest = await readById(page, { id: args.id, spaceId: args.spaceId }) + + return { + ok: true, + document: latest.document, + } +} + +async function list(page: Page, args: ListArgs = {}) { + let appPath = args.spaceId ? `/app/spaces/${args.spaceId}` : "/app" + await waitForEditorBoot(page, { path: appPath }) + + let search = args.search ?? "" + await page.getByTestId(testIds.doc.searchInput).fill(search) + + let rows = page.getByTestId(testIds.doc.listItem) + let items = await rows.evaluateAll(elements => { + return elements.map(element => { + let id = element.getAttribute("data-doc-id") ?? "" + let title = element.getAttribute("data-doc-title") ?? "" + let tagsRaw = element.getAttribute("data-doc-tags") ?? "" + let path = element.getAttribute("data-doc-path") ?? "" + let date = element.getAttribute("data-doc-date") ?? "" + + return { + id, + title, + tags: tagsRaw ? tagsRaw.split(",").filter(Boolean) : [], + path: path || null, + date, + } + }) + }) + + return { + ok: true, + spaceId: args.spaceId ?? null, + search, + count: items.length, + items, + } +} + +async function deleteById(page: Page, args: DeleteByIdArgs) { + let targetPath = getDocPath(args.id, args.spaceId) + await waitForEditorBoot(page, { path: targetPath }) + + let fileMenuButton = page.getByTestId(testIds.doc.fileMenuButton) + let fileMenuHandle = await fileMenuButton.elementHandle() + if (!fileMenuHandle) { + throw new Error("Could not find file menu button") + } + await fileMenuHandle.evaluate(element => { + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ) + }) + await page.getByTestId(testIds.doc.deleteButton).click() + await page.getByTestId(testIds.dialog.deleteConfirm).click() + + await expect + .poll(async () => { + let target = page.locator(`[data-doc-id="${args.id}"]`) + return target.count() + }) + .toBe(0) + + return { + ok: true, + id: args.id, + spaceId: args.spaceId ?? null, + deleted: true, + } +} + +function getDocPath(id: string, spaceId?: string) { + if (spaceId) { + return `/app/spaces/${spaceId}/doc/${id}` + } + return `/app/doc/${id}` +} + +function getDocIdFromUrl(url: string) { + let match = url.match(/\/doc\/([^/?#]+)/) + return match?.[1] ?? null +} + +function buildContent(args: { + title?: string + body?: string + tags?: string[] + path?: string +}) { + let title = args.title?.trim() || "Untitled" + let body = args.body ?? "" + let frontmatterLines: string[] = [] + + if (args.path && args.path.trim()) { + frontmatterLines.push(`path: ${args.path.trim()}`) + } + + if (args.tags && args.tags.length > 0) { + frontmatterLines.push(`tags: ${args.tags.join(", ")}`) + } + + let frontmatter = + frontmatterLines.length > 0 + ? `---\n${frontmatterLines.join("\n")}\n---\n\n` + : "" + + if (!body) { + return `${frontmatter}# ${title}\n` + } + + return `${frontmatter}# ${title}\n\n${body}` +} + +function inferTitleFromContent(content: string) { + let heading = content.match(/^#\s+(.+)$/m) + if (!heading) return "Untitled" + return heading[1].trim() +} + +async function setEditorContent(page: Page, content: string) { + let editor = getEditorLocator(page) + await editor.click() + await editor.press("Control+A") + await editor.fill(content) +} + +async function getEditorContent(page: Page) { + let editor = getEditorLocator(page) + return editor.innerText() +} + +function getEditorLocator(page: Page) { + return page.locator('[data-testid="doc-editor"] .cm-content').first() +} + +async function readListItemById(page: Page, docId: string) { + let row = page.locator( + `[data-testid="${testIds.doc.listItem}"][data-doc-id="${docId}"]`, + ) + if ((await row.count()) === 0) { + return null + } + + let first = row.first() + let title = (await first.getAttribute("data-doc-title")) ?? "" + let tagsRaw = (await first.getAttribute("data-doc-tags")) ?? "" + let path = (await first.getAttribute("data-doc-path")) ?? "" + let date = (await first.getAttribute("data-doc-date")) ?? "" + + return { + title, + tags: tagsRaw ? tagsRaw.split(",").filter(Boolean) : [], + path: path || null, + date, + } +} diff --git a/skills/alkalye-playwright-crud/helpers/document-collab-helpers.ts b/skills/alkalye-playwright-crud/helpers/document-collab-helpers.ts new file mode 100644 index 0000000..8816c7b --- /dev/null +++ b/skills/alkalye-playwright-crud/helpers/document-collab-helpers.ts @@ -0,0 +1,165 @@ +import { expect, type Page } from "@playwright/test" +import { testIds } from "@/lib/test-ids" +import { waitForEditorBoot } from "./auth-helpers" + +export { + createDocumentInvite, + listDocumentInvites, + revokeDocumentInvite, + acceptDocumentInvite, +} + +interface DocArgs { + docId: string + spaceId?: string +} + +interface CreateDocumentInviteArgs extends DocArgs { + role: "writer" | "reader" +} + +interface RevokeDocumentInviteArgs extends DocArgs { + inviteGroupId?: string +} + +interface AcceptDocumentInviteArgs { + link: string +} + +async function createDocumentInvite( + page: Page, + args: CreateDocumentInviteArgs, +) { + await openDocumentShareDialog(page, args) + + let buttonId = + args.role === "writer" + ? testIds.collab.docShareInviteWriterButton + : testIds.collab.docShareInviteReaderButton + + await page.getByTestId(buttonId).click() + let input = page.getByTestId(testIds.collab.docShareInviteLinkInput) + await expect(input).toBeVisible({ timeout: 10_000 }) + let link = await input.inputValue() + let inviteGroupId = parseInviteGroupId(link) + + return { + ok: true, + docId: args.docId, + spaceId: args.spaceId ?? null, + role: args.role, + inviteGroupId, + link, + } +} + +async function listDocumentInvites(page: Page, args: DocArgs) { + await openDocumentShareDialog(page, args) + + let items = await page + .getByTestId(testIds.collab.docSharePendingInviteRow) + .evaluateAll(rows => { + return rows.map(row => ({ + inviteGroupId: row.getAttribute("data-invite-group-id") ?? "", + })) + }) + + return { + ok: true, + docId: args.docId, + spaceId: args.spaceId ?? null, + count: items.length, + items, + } +} + +async function revokeDocumentInvite( + page: Page, + args: RevokeDocumentInviteArgs, +) { + await openDocumentShareDialog(page, args) + + if (args.inviteGroupId) { + await page + .locator( + `[data-testid="${testIds.collab.docSharePendingInviteRevoke}"][data-invite-group-id="${args.inviteGroupId}"]`, + ) + .first() + .click() + } else { + await page + .getByTestId(testIds.collab.docSharePendingInviteRevoke) + .first() + .click() + } + + return { + ok: true, + docId: args.docId, + spaceId: args.spaceId ?? null, + revoked: true, + inviteGroupId: args.inviteGroupId ?? null, + } +} + +async function acceptDocumentInvite( + page: Page, + args: AcceptDocumentInviteArgs, +) { + let browser = page.context().browser() + if (!browser) throw new Error("Browser instance unavailable") + + let docId = parseDocIdFromInviteLink(args.link) + let inviteGroupId = parseInviteGroupId(args.link) + + let context = await browser.newContext() + let invitePage = await context.newPage() + + await invitePage.goto(args.link) + await expect(invitePage).toHaveURL(/\/app\/invite/) + await invitePage.getByTestId(testIds.invite.signInButton).click() + await invitePage.getByTestId(testIds.auth.initialCreateAccount).click() + await invitePage.getByTestId(testIds.auth.createCopy).click() + await invitePage.getByTestId(testIds.auth.createSubmit).click() + + if (docId) { + await expect.poll(() => invitePage.url()).toContain(`/app/doc/${docId}`) + } + + let url = invitePage.url() + await context.close() + + return { + ok: true, + docId, + inviteGroupId, + url, + } +} + +async function openDocumentShareDialog(page: Page, args: DocArgs) { + let path = args.spaceId + ? `/app/spaces/${args.spaceId}/doc/${args.docId}` + : `/app/doc/${args.docId}` + + await waitForEditorBoot(page, { path }) + let shareButton = page.getByTestId(testIds.collab.docShareOpenButton) + let handle = await shareButton.elementHandle() + if (!handle) throw new Error("Could not find document share button") + await handle.evaluate(element => { + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ) + }) + await expect(page.getByTestId(testIds.collab.docShareDialog)).toBeVisible() +} + +function parseInviteGroupId(link: string) { + let match = link.match(/\/invite\/(co_[^/]+)\//) + return match?.[1] ?? null +} + +function parseDocIdFromInviteLink(link: string) { + let match = link.match(/#\/doc\/(co_[^/]+)\//) + return match?.[1] ?? null +} diff --git a/skills/alkalye-playwright-crud/helpers/space-helpers.ts b/skills/alkalye-playwright-crud/helpers/space-helpers.ts new file mode 100644 index 0000000..8288093 --- /dev/null +++ b/skills/alkalye-playwright-crud/helpers/space-helpers.ts @@ -0,0 +1,308 @@ +import { expect, type Page } from "@playwright/test" +import { testIds } from "@/lib/test-ids" +import { waitForEditorBoot } from "./auth-helpers" + +export { + createSpace, + readSpaceById, + updateSpaceById, + listSpaces, + deleteSpaceById, + createSpaceInvite, + listSpaceInvites, + revokeSpaceInvite, + acceptSpaceInvite, +} + +interface SpaceArgs { + spaceId?: string +} + +interface CreateSpaceArgs { + name: string +} + +interface UpdateSpaceByIdArgs { + spaceId: string + name: string +} + +interface ReadSpaceByIdArgs { + spaceId: string +} + +interface DeleteSpaceByIdArgs { + spaceId: string +} + +interface CreateSpaceInviteArgs { + spaceId: string + role: "writer" | "reader" +} + +interface RevokeSpaceInviteArgs { + spaceId: string + inviteGroupId?: string +} + +interface AcceptSpaceInviteArgs { + link: string +} + +async function createSpace(page: Page, args: CreateSpaceArgs) { + await waitForEditorBoot(page) + await openSpaceSelector(page) + await page.getByTestId(testIds.space.createButton).click() + + let dialog = page.getByTestId(testIds.space.createDialog) + await expect(dialog).toBeVisible() + + await page.getByTestId(testIds.space.createNameInput).fill(args.name) + await page.getByTestId(testIds.space.createSubmit).click() + + await expect.poll(() => page.url()).toContain("/app/spaces/") + + let spaceId = getSpaceIdFromUrl(page.url()) + if (!spaceId) throw new Error(`Could not parse space id from ${page.url()}`) + + return { + ok: true, + id: spaceId, + name: args.name, + url: page.url(), + } +} + +async function readSpaceById(page: Page, args: ReadSpaceByIdArgs) { + await page.goto(`/app/spaces/${args.spaceId}/settings`) + await expect(page.getByTestId(testIds.space.settingsNameInput)).toBeVisible() + let name = await page + .getByTestId(testIds.space.settingsNameInput) + .inputValue() + + return { + ok: true, + space: { + id: args.spaceId, + name, + }, + } +} + +async function updateSpaceById(page: Page, args: UpdateSpaceByIdArgs) { + await page.goto(`/app/spaces/${args.spaceId}/settings`) + await expect(page.getByTestId(testIds.space.settingsNameInput)).toBeVisible() + let input = page.getByTestId(testIds.space.settingsNameInput) + await input.fill(args.name) + + await expect + .poll(async () => { + return input.inputValue() + }) + .toBe(args.name) + + return { + ok: true, + space: { + id: args.spaceId, + name: args.name, + }, + } +} + +async function listSpaces(page: Page) { + await waitForEditorBoot(page) + await openSpaceSelector(page) + + let items = await page + .getByTestId(testIds.space.listItem) + .evaluateAll(rows => { + return rows.map(row => { + let id = row.getAttribute("data-space-id") ?? "" + let name = row.textContent?.trim() ?? "" + return { id, name } + }) + }) + + await page.keyboard.press("Escape") + + return { + ok: true, + count: items.length, + items, + } +} + +async function deleteSpaceById(page: Page, args: DeleteSpaceByIdArgs) { + await page.goto(`/app/spaces/${args.spaceId}/settings`) + await expect(page.getByTestId(testIds.space.settingsNameInput)).toBeVisible() + + let name = await page + .getByTestId(testIds.space.settingsNameInput) + .inputValue() + await page.getByTestId(testIds.space.dangerDeleteButton).click() + await page.getByTestId(testIds.space.dangerDeleteNameInput).fill(name) + await page + .getByTestId(testIds.space.dangerDeletePhraseInput) + .fill("yes, delete permanently") + await page.getByTestId(testIds.space.dangerDeleteConfirmButton).click() + + await expect.poll(() => page.url()).toContain("/app") + + return { + ok: true, + deleted: true, + id: args.spaceId, + } +} + +async function createSpaceInvite(page: Page, args: CreateSpaceInviteArgs) { + await openSpaceShareDialog(page, { spaceId: args.spaceId }) + + let buttonId = + args.role === "writer" + ? testIds.space.shareWriterInviteButton + : testIds.space.shareReaderInviteButton + + await page.getByTestId(buttonId).click() + + let input = page.getByTestId(testIds.space.shareInviteLinkInput) + await expect(input).toBeVisible({ timeout: 10_000 }) + let link = await input.inputValue() + let inviteGroupId = parseInviteGroupId(link) + + return { + ok: true, + spaceId: args.spaceId, + role: args.role, + inviteGroupId, + link, + } +} + +async function listSpaceInvites(page: Page, args: SpaceArgs) { + if (!args.spaceId) throw new Error("spaceId required") + await openSpaceShareDialog(page, { spaceId: args.spaceId }) + + let invites = await page + .getByTestId(testIds.space.sharePendingInviteRow) + .evaluateAll(rows => { + return rows.map(row => { + return { + inviteGroupId: row.getAttribute("data-invite-group-id") ?? "", + } + }) + }) + + return { + ok: true, + spaceId: args.spaceId, + count: invites.length, + items: invites, + } +} + +async function revokeSpaceInvite(page: Page, args: RevokeSpaceInviteArgs) { + await openSpaceShareDialog(page, { spaceId: args.spaceId }) + + if (args.inviteGroupId) { + await page + .locator( + `[data-testid="${testIds.space.sharePendingInviteRevoke}"][data-invite-group-id="${args.inviteGroupId}"]`, + ) + .first() + .click() + } else { + await page + .getByTestId(testIds.space.sharePendingInviteRevoke) + .first() + .click() + } + + if (args.inviteGroupId) { + await expect + .poll(async () => { + return page + .locator( + `[data-testid="${testIds.space.sharePendingInviteRow}"][data-invite-group-id="${args.inviteGroupId}"]`, + ) + .count() + }) + .toBe(0) + } + + return { + ok: true, + spaceId: args.spaceId, + revoked: true, + inviteGroupId: args.inviteGroupId ?? null, + } +} + +async function acceptSpaceInvite(page: Page, args: AcceptSpaceInviteArgs) { + let browser = page.context().browser() + if (!browser) throw new Error("Browser instance unavailable") + + let inviteGroupId = parseInviteGroupId(args.link) + let spaceId = parseSpaceIdFromInviteLink(args.link) + let context = await browser.newContext() + let invitePage = await context.newPage() + + await invitePage.goto(args.link) + await expect(invitePage).toHaveURL(/\/app\/invite/) + await invitePage.getByTestId(testIds.invite.signInButton).click() + + await invitePage.getByTestId(testIds.auth.initialCreateAccount).click() + await invitePage.getByTestId(testIds.auth.createCopy).click() + await invitePage.getByTestId(testIds.auth.createSubmit).click() + + if (spaceId) { + await expect + .poll(() => invitePage.url()) + .toContain(`/app/spaces/${spaceId}`) + } + + let url = invitePage.url() + await context.close() + + return { + ok: true, + spaceId, + inviteGroupId, + url, + } +} + +async function openSpaceSelector(page: Page) { + let trigger = page.getByTestId(testIds.space.selectorTrigger) + let handle = await trigger.elementHandle() + if (!handle) throw new Error("Could not find space selector trigger") + await handle.evaluate(element => { + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ) + }) + await expect(page.getByTestId(testIds.space.createButton)).toBeVisible() +} + +async function openSpaceShareDialog(page: Page, args: { spaceId: string }) { + await page.goto(`/app/spaces/${args.spaceId}/settings`) + await expect(page.getByTestId(testIds.space.inviteButton)).toBeVisible() + await page.getByTestId(testIds.space.inviteButton).click() + await expect(page.getByTestId(testIds.space.shareDialog)).toBeVisible() +} + +function getSpaceIdFromUrl(url: string) { + let match = url.match(/\/spaces\/([^/?#]+)/) + return match?.[1] ?? null +} + +function parseInviteGroupId(link: string) { + let match = link.match(/\/invite\/(co_[^/]+)\//) + return match?.[1] ?? null +} + +function parseSpaceIdFromInviteLink(link: string) { + let match = link.match(/#\/space\/(co_[^/]+)\//) + return match?.[1] ?? null +} diff --git a/skills/alkalye-playwright-crud/scripts/EXAMPLE.md b/skills/alkalye-playwright-crud/scripts/EXAMPLE.md new file mode 100644 index 0000000..94c18b9 --- /dev/null +++ b/skills/alkalye-playwright-crud/scripts/EXAMPLE.md @@ -0,0 +1,39 @@ +# Single-Run Script Pattern + +Use this pattern when an agent should complete a task in one execution. + +```ts +import { chromium } from "@playwright/test" +import { createAccount, waitForEditorBoot } from "../helpers/auth-helpers" +import { create, list } from "../helpers/doc-helpers" + +async function run() { + let browser = await chromium.launch() + let context = await browser.newContext() + let page = await context.newPage() + + await waitForEditorBoot(page) + await createAccount(page) + let created = await create(page, { title: "Automated Doc", body: "Hello" }) + let listed = await list(page, { search: "Automated Doc" }) + + console.log( + JSON.stringify( + { + ok: true, + created, + listed, + }, + null, + 2, + ), + ) + + await context.close() + await browser.close() +} + +void run() +``` + +Run with Bun from repo root after dev server is available. diff --git a/skills/alkalye-playwright-crud/scripts/payload.example.json b/skills/alkalye-playwright-crud/scripts/payload.example.json new file mode 100644 index 0000000..ecf32b4 --- /dev/null +++ b/skills/alkalye-playwright-crud/scripts/payload.example.json @@ -0,0 +1,17 @@ +{ + "headless": true, + "steps": [ + { "task": "auth.waitForEditorBoot" }, + { "task": "auth.createAccount" }, + { + "task": "doc.create", + "args": { + "title": "Skill Script Doc", + "body": "Created by run-task.ts", + "tags": ["skill", "script"], + "path": "automation" + } + }, + { "task": "doc.list", "args": { "search": "Skill Script Doc" } } + ] +} diff --git a/skills/alkalye-playwright-crud/scripts/run-task.ts b/skills/alkalye-playwright-crud/scripts/run-task.ts new file mode 100644 index 0000000..fe81c6f --- /dev/null +++ b/skills/alkalye-playwright-crud/scripts/run-task.ts @@ -0,0 +1,330 @@ +import { chromium, type Page } from "@playwright/test" +import { readFile } from "node:fs/promises" +import { + createAccount, + openSettings, + signIn, + signOut, + waitForEditorBoot, +} from "../helpers/auth-helpers" +import { + create, + deleteById, + list, + readById, + updateById, +} from "../helpers/doc-helpers" +import { + acceptSpaceInvite, + createSpace, + createSpaceInvite, + deleteSpaceById, + listSpaceInvites, + listSpaces, + readSpaceById, + revokeSpaceInvite, + updateSpaceById, +} from "../helpers/space-helpers" +import { + acceptDocumentInvite, + createDocumentInvite, + listDocumentInvites, + revokeDocumentInvite, +} from "../helpers/document-collab-helpers" + +type Step = { + task: string + args?: Record +} + +type Payload = { + baseURL?: string + headless?: boolean + steps: Step[] +} + +async function main() { + let payload = await loadPayload() + let baseURL = + payload.baseURL ?? + process.env.PLAYWRIGHT_BASE_URL ?? + "http://127.0.0.1:4173" + let browser = await chromium.launch({ headless: payload.headless ?? true }) + let context = await browser.newContext({ baseURL }) + let page = await context.newPage() + + let results: Array<{ task: string; result: unknown }> = [] + + try { + for (let step of payload.steps) { + let result = await runStep(page, step) + results.push({ task: step.task, result }) + } + + writeJson({ ok: true, baseURL, steps: results }) + } finally { + await context.close() + await browser.close() + } +} + +async function runStep(page: Page, step: Step) { + let args = step.args ?? {} + + switch (step.task) { + case "auth.waitForEditorBoot": + return waitForEditorBoot(page, { + path: getOptionalString(args, "path"), + }) + case "auth.openSettings": + return openSettings(page, { + fromPath: getOptionalString(args, "fromPath"), + }) + case "auth.createAccount": + return createAccount(page, { + openSettings: getOptionalBoolean(args, "openSettings"), + }) + case "auth.signIn": + return signIn(page, { + passphrase: getRequiredString(args, "passphrase"), + openSettings: getOptionalBoolean(args, "openSettings"), + }) + case "auth.signOut": + return signOut(page, { + openSettings: getOptionalBoolean(args, "openSettings"), + }) + + case "doc.create": + return create(page, { + title: getOptionalString(args, "title"), + body: getOptionalString(args, "body"), + content: getOptionalString(args, "content"), + tags: getOptionalStringArray(args, "tags"), + path: getOptionalString(args, "path"), + spaceId: getOptionalString(args, "spaceId"), + }) + case "doc.readById": + return readById(page, { + id: getRequiredString(args, "id"), + spaceId: getOptionalString(args, "spaceId"), + }) + case "doc.updateById": + return updateById(page, { + id: getRequiredString(args, "id"), + title: getOptionalString(args, "title"), + body: getOptionalString(args, "body"), + content: getOptionalString(args, "content"), + tags: getOptionalStringArray(args, "tags"), + path: getOptionalString(args, "path"), + spaceId: getOptionalString(args, "spaceId"), + }) + case "doc.list": + return list(page, { + search: getOptionalString(args, "search"), + spaceId: getOptionalString(args, "spaceId"), + }) + case "doc.deleteById": + return deleteById(page, { + id: getRequiredString(args, "id"), + spaceId: getOptionalString(args, "spaceId"), + }) + + case "space.create": + return createSpace(page, { + name: getRequiredString(args, "name"), + }) + case "space.readById": + return readSpaceById(page, { + spaceId: getRequiredString(args, "spaceId"), + }) + case "space.updateById": + return updateSpaceById(page, { + spaceId: getRequiredString(args, "spaceId"), + name: getRequiredString(args, "name"), + }) + case "space.list": + return listSpaces(page) + case "space.deleteById": + return deleteSpaceById(page, { + spaceId: getRequiredString(args, "spaceId"), + }) + case "space.createInvite": + return createSpaceInvite(page, { + spaceId: getRequiredString(args, "spaceId"), + role: getRequiredInviteRole(args, "role"), + }) + case "space.listInvites": + return listSpaceInvites(page, { + spaceId: getRequiredString(args, "spaceId"), + }) + case "space.revokeInvite": + return revokeSpaceInvite(page, { + spaceId: getRequiredString(args, "spaceId"), + inviteGroupId: getOptionalString(args, "inviteGroupId"), + }) + case "space.acceptInvite": + return acceptSpaceInvite(page, { + link: getRequiredString(args, "link"), + }) + + case "collab.doc.createInvite": + return createDocumentInvite(page, { + docId: getRequiredString(args, "docId"), + spaceId: getOptionalString(args, "spaceId"), + role: getRequiredInviteRole(args, "role"), + }) + case "collab.doc.listInvites": + return listDocumentInvites(page, { + docId: getRequiredString(args, "docId"), + spaceId: getOptionalString(args, "spaceId"), + }) + case "collab.doc.revokeInvite": + return revokeDocumentInvite(page, { + docId: getRequiredString(args, "docId"), + spaceId: getOptionalString(args, "spaceId"), + inviteGroupId: getOptionalString(args, "inviteGroupId"), + }) + case "collab.doc.acceptInvite": + return acceptDocumentInvite(page, { + link: getRequiredString(args, "link"), + }) + + default: + throw new Error(`Unsupported task: ${step.task}`) + } +} + +async function loadPayload(): Promise { + let raw = await loadRawInput() + let parsed = parseJson(raw) + if (!isRecord(parsed)) { + throw new Error("Payload must be an object") + } + + let stepsValue = parsed.steps + if (!Array.isArray(stepsValue) || stepsValue.length === 0) { + throw new Error("Payload.steps must be a non-empty array") + } + + let steps: Step[] = [] + for (let value of stepsValue) { + if (!isRecord(value)) { + throw new Error("Each step must be an object") + } + let task = value.task + if (typeof task !== "string" || task.length === 0) { + throw new Error("Each step.task must be a non-empty string") + } + let args = value.args + if (args !== undefined && !isRecord(args)) { + throw new Error("Step args must be an object when provided") + } + steps.push({ task, args }) + } + + return { + baseURL: getOptionalString(parsed, "baseURL"), + headless: getOptionalBoolean(parsed, "headless"), + steps, + } +} + +async function loadRawInput() { + let args = process.argv.slice(2) + if (args.length > 0) { + if (args[0] === "--file") { + let filePath = args[1] + if (!filePath) throw new Error("Missing file path after --file") + return readFile(filePath, "utf-8") + } + if (args[0].startsWith("@")) { + return readFile(args[0].slice(1), "utf-8") + } + return args.join(" ") + } + + let stdin = await readStdin() + if (!stdin.trim()) { + throw new Error("Provide JSON via stdin, --file , @, or inline") + } + return stdin +} + +async function readStdin() { + if (process.stdin.isTTY) return "" + let chunks: Buffer[] = [] + for await (let chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString("utf-8") +} + +function parseJson(value: string) { + try { + return JSON.parse(value) + } catch { + throw new Error("Invalid JSON payload") + } +} + +function getRequiredString(source: Record, key: string) { + let value = source[key] + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Expected non-empty string at ${key}`) + } + return value +} + +function getOptionalString(source: Record, key: string) { + let value = source[key] + if (value === undefined) return undefined + if (typeof value !== "string") { + throw new Error(`Expected string at ${key}`) + } + return value +} + +function getOptionalBoolean(source: Record, key: string) { + let value = source[key] + if (value === undefined) return undefined + if (typeof value !== "boolean") { + throw new Error(`Expected boolean at ${key}`) + } + return value +} + +function getOptionalStringArray(source: Record, key: string) { + let value = source[key] + if (value === undefined) return undefined + if (!Array.isArray(value)) { + throw new Error(`Expected string[] at ${key}`) + } + for (let item of value) { + if (typeof item !== "string") { + throw new Error(`Expected string[] at ${key}`) + } + } + return value +} + +function getRequiredInviteRole(source: Record, key: string) { + let value = source[key] + if (value !== "writer" && value !== "reader") { + throw new Error(`Expected role writer|reader at ${key}`) + } + return value +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function writeJson(value: unknown) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) +} + +void main().catch(error => { + let message = error instanceof Error ? error.message : "Unknown error" + writeJson({ ok: false, error: message }) + process.exitCode = 1 +}) diff --git a/src/app/routes/doc.$id.index.tsx b/src/app/routes/doc.$id.index.tsx index f5e6539..d15794c 100644 --- a/src/app/routes/doc.$id.index.tsx +++ b/src/app/routes/doc.$id.index.tsx @@ -80,6 +80,7 @@ import { usePWA } from "@/lib/pwa" import { HelpMenu } from "@/components/help-menu" import { useTrackLastOpened } from "@/lib/use-track-last-opened" import { printToPdf } from "@/lib/print-to-pdf" +import { testIds } from "@/lib/test-ids" export { Route } function setAutomationReadyState(ready: boolean, route: string) { @@ -418,6 +419,7 @@ function EditorContent({ doc, docId }: EditorContentProps) { @@ -253,7 +261,10 @@ function NeedsAuthState({ function RevokedState() { return ( -
+

Sorry, this invite expired :(

diff --git a/src/app/routes/settings.tsx b/src/app/routes/settings.tsx index f1edb15..2aa0dd9 100644 --- a/src/app/routes/settings.tsx +++ b/src/app/routes/settings.tsx @@ -67,6 +67,7 @@ import { TooltipContent, } from "@/components/ui/tooltip" import { useIsOnline } from "@/lib/use-online" +import { testIds } from "@/lib/test-ids" export { Route } @@ -655,7 +656,12 @@ function SignInView() { Local only
- Show recovery phrase -
diff --git a/src/app/routes/spaces.$spaceId.doc.$id.index.tsx b/src/app/routes/spaces.$spaceId.doc.$id.index.tsx index e8d300f..39499bb 100644 --- a/src/app/routes/spaces.$spaceId.doc.$id.index.tsx +++ b/src/app/routes/spaces.$spaceId.doc.$id.index.tsx @@ -82,6 +82,7 @@ import { usePWA } from "@/lib/pwa" import { HelpMenu } from "@/components/help-menu" import { useTrackLastOpened } from "@/lib/use-track-last-opened" import { printToPdf } from "@/lib/print-to-pdf" +import { testIds } from "@/lib/test-ids" export { Route } @@ -425,6 +426,7 @@ function SpaceEditorContent({
@@ -552,6 +554,7 @@ function SpaceMembersSection({ space }: { space: LoadedSpace }) { variant="outline" size="sm" onClick={() => setShareOpen(true)} + data-testid={testIds.space.inviteButton} > Invite @@ -804,6 +807,7 @@ function DangerZoneSection({ space }: { space: LoadedSpace }) { size="sm" disabled={!canDelete} onClick={() => setDeleteDialogOpen(true)} + data-testid={testIds.space.dangerDeleteButton} > Delete @@ -887,6 +891,7 @@ function PermanentDeleteSpaceDialog({ onChange={e => setNameInput(e.target.value)} placeholder={spaceName} autoComplete="off" + data-testid={testIds.space.dangerDeleteNameInput} />
@@ -902,6 +907,7 @@ function PermanentDeleteSpaceDialog({ onChange={e => setConfirmInput(e.target.value)} placeholder={CONFIRM_PHRASE} autoComplete="off" + data-testid={testIds.space.dangerDeletePhraseInput} />
@@ -913,6 +919,7 @@ function PermanentDeleteSpaceDialog({ variant="destructive" onClick={handleConfirm} disabled={!canDelete} + data-testid={testIds.space.dangerDeleteConfirmButton} > Delete permanently diff --git a/src/components/auth-form.tsx b/src/components/auth-form.tsx index dca8ea5..45c75ca 100644 --- a/src/components/auth-form.tsx +++ b/src/components/auth-form.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { wordlist } from "@/lib/wordlist" +import { testIds } from "@/lib/test-ids" import { getRandomWriterName } from "@/schema" export { AuthForm, AuthDialog } @@ -41,7 +42,7 @@ function AuthDialog({ return ( - + {title} {description && {description}} @@ -68,8 +69,18 @@ function AuthForm({ onSuccess }: AuthFormProps) { } async function handleCopy() { - await navigator.clipboard.writeText(currentPassphrase) - setIsCopied(true) + try { + await navigator.clipboard.writeText(currentPassphrase) + setError("") + setIsCopied(true) + } catch (e) { + setIsCopied(false) + setError( + e instanceof Error + ? e.message + : "Could not copy recovery phrase. Please copy it manually.", + ) + } } async function handleRegister() { @@ -101,13 +112,18 @@ function AuthForm({ onSuccess }: AuthFormProps) { to sync your notes across devices and collaborate with others.

- @@ -126,6 +142,7 @@ function AuthForm({ onSuccess }: AuthFormProps) {