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) {
-
+
+
{status === "loading"
@@ -238,7 +242,11 @@ function NeedsAuthState({
-
@@ -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
-
setAuthOpen(true)} size="sm" variant="outline">
+ setAuthOpen(true)}
+ size="sm"
+ variant="outline"
+ data-testid={testIds.auth.settingsSignIn}
+ >
Sign in
Show recovery phrase
-
logOut()} variant="ghost" size="sm">
+ logOut()}
+ variant="ghost"
+ size="sm"
+ data-testid={testIds.auth.settingsSignOut}
+ >
Sign out
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 (
- setStep("create")} size="sm">
+ setStep("create")}
+ size="sm"
+ data-testid={testIds.auth.initialCreateAccount}
+ >
Create new account
setStep("login")}
variant="outline"
size="sm"
+ data-testid={testIds.auth.initialSignIn}
>
Sign in
@@ -126,6 +142,7 @@ function AuthForm({ onSuccess }: AuthFormProps) {
@@ -135,6 +152,7 @@ function AuthForm({ onSuccess }: AuthFormProps) {
variant="outline"
size="sm"
className="flex-1"
+ data-testid={testIds.auth.createCopy}
>
{isCopied ? (
<>
@@ -148,17 +166,30 @@ function AuthForm({ onSuccess }: AuthFormProps) {
>
)}
-
+
- {error && {error}
}
+ {error && (
+
+ {error}
+
+ )}
setStep("initial")}
variant="ghost"
size="sm"
className="flex-1"
+ data-testid={testIds.auth.createBack}
>
Back
@@ -167,6 +198,7 @@ function AuthForm({ onSuccess }: AuthFormProps) {
size="sm"
disabled={!isCopied}
className="flex-1"
+ data-testid={testIds.auth.createSubmit}
>
Create account
@@ -184,10 +216,18 @@ function AuthForm({ onSuccess }: AuthFormProps) {
value={loginPassphrase}
onChange={e => setLoginPassphrase(e.target.value)}
placeholder="word1 word2 word3 ..."
+ data-testid={testIds.auth.loginPassphrase}
className="bg-background border-border mb-3 w-full resize-none rounded-md border p-3 font-mono text-sm"
minRows={3}
/>
- {error &&
{error}
}
+ {error && (
+
+ {error}
+
+ )}
{
@@ -197,6 +237,7 @@ function AuthForm({ onSuccess }: AuthFormProps) {
variant="ghost"
size="sm"
className="flex-1"
+ data-testid={testIds.auth.loginBack}
>
Back
@@ -205,6 +246,7 @@ function AuthForm({ onSuccess }: AuthFormProps) {
size="sm"
disabled={!loginPassphrase.trim()}
className="flex-1"
+ data-testid={testIds.auth.loginSubmit}
>
Sign in
diff --git a/src/components/share-dialog.tsx b/src/components/share-dialog.tsx
index cd4b364..b905207 100644
--- a/src/components/share-dialog.tsx
+++ b/src/components/share-dialog.tsx
@@ -37,6 +37,7 @@ import {
getPublicLink,
type Collaborator,
} from "@/lib/documents"
+import { testIds } from "@/lib/test-ids"
export { ShareDialog }
@@ -210,7 +211,7 @@ function ShareDialog({
return (