From 3b98cbc5f0a13b1fc31c55bb595ff681449e64cd Mon Sep 17 00:00:00 2001 From: Carl Assmann Date: Thu, 14 May 2026 12:44:00 +0200 Subject: [PATCH 1/4] chore: add tests + e2e tests & improve tool calls --- CONTEXT.md | 22 ++ bun.lock | 9 + e2e/note-helpers.ts | 102 ++++++ e2e/note.spec.ts | 49 +++ e2e/person-helpers.ts | 105 ++++++ e2e/person.spec.ts | 47 +++ e2e/reminder-helpers.ts | 121 +++++++ e2e/reminder.spec.ts | 86 +++++ e2e/setup-helpers.ts | 22 ++ eslint.config.js | 10 + package.json | 3 + playwright.config.ts | 26 ++ .../assistant/parts/note-create-result.tsx | 25 +- .../assistant/parts/note-delete-result.tsx | 21 +- .../assistant/parts/note-update-result.tsx | 41 +-- .../parts/person-create-confirmation.tsx | 7 +- .../assistant/parts/person-create-result.tsx | 24 +- .../assistant/parts/person-delete-result.tsx | 22 +- .../assistant/parts/person-update-result.tsx | 15 +- .../parts/reminder-create-result.tsx | 29 +- .../parts/reminder-delete-result.tsx | 27 +- .../parts/reminder-update-result.tsx | 61 ++-- src/app/features/notes/lib/note-actions.ts | 79 +++-- .../notes/parts/note-content-editor.tsx | 3 + .../features/notes/widgets/active-note.tsx | 22 +- .../features/notes/widgets/deleted-note.tsx | 15 +- .../notes/widgets/inline-note-form.tsx | 3 + src/app/features/notes/widgets/new-note.tsx | 21 +- src/app/features/notes/widgets/note-form.tsx | 3 + .../features/people/lib/list-operations.ts | 6 +- src/app/features/people/lib/person-actions.ts | 48 +-- .../people/lib/person-sharing.test.ts | 282 +++++++++++++++ src/app/features/people/lib/person-sharing.ts | 50 ++- .../people/parts/people-fallbacks.tsx | 5 +- .../features/people/screens/people-screen.tsx | 3 +- .../people/widgets/actions-dropdown.tsx | 36 +- .../features/people/widgets/active-person.tsx | 9 +- .../people/widgets/deleted-person.tsx | 15 +- .../people/widgets/edit-list-dialog.tsx | 5 +- .../people/widgets/manage-lists-dialog.tsx | 4 +- .../people/widgets/new-list-dialog.tsx | 2 +- .../people/widgets/new-person-note.tsx | 6 +- .../people/widgets/new-person-reminder.tsx | 6 +- .../features/people/widgets/new-person.tsx | 5 +- .../people/widgets/person-details.tsx | 19 +- .../features/people/widgets/person-form.tsx | 9 +- .../reminders/lib/reminder-actions.ts | 52 +-- .../reminders/parts/reminder-fields.tsx | 4 + .../reminders/widgets/active-reminder.tsx | 22 +- .../reminders/widgets/deleted-reminder.tsx | 15 +- .../reminders/widgets/done-reminder.tsx | 149 ++++---- .../reminders/widgets/new-reminder.tsx | 19 +- .../reminders/widgets/reminder-form.tsx | 4 + src/app/features/tour/screens/tour-screen.tsx | 2 + src/app/hooks/use-cleanups.ts | 10 +- src/shared/lib/test-ids.ts | 50 +++ src/shared/tools/define-tool.test.ts | 147 ++++++++ src/shared/tools/define-tool.ts | 141 ++++++++ src/shared/tools/note-create.test.ts | 124 +++++++ src/shared/tools/note-create.ts | 133 ++++--- src/shared/tools/note-read.test.ts | 87 +++++ src/shared/tools/note-read.ts | 148 ++++---- src/shared/tools/note-update.test.ts | 124 +++++++ src/shared/tools/note-update.ts | 267 ++++++-------- src/shared/tools/person-create.test.ts | 45 +++ src/shared/tools/person-create.ts | 129 +++---- src/shared/tools/person-read.test.ts | 130 +++++++ src/shared/tools/person-read.ts | 331 ++++++++++-------- src/shared/tools/person-update.test.ts | 117 +++++++ src/shared/tools/person-update.ts | 207 ++++------- src/shared/tools/reminder-create.test.ts | 90 +++++ src/shared/tools/reminder-create.ts | 141 ++++---- src/shared/tools/reminder-read.test.ts | 114 ++++++ src/shared/tools/reminder-read.ts | 225 ++++++------ src/shared/tools/reminder-update.test.ts | 191 ++++++++++ src/shared/tools/reminder-update.ts | 254 ++++++-------- src/shared/tools/test-helpers.ts | 99 ++++++ src/shared/tools/user-question.ts | 70 ++-- src/shared/ui/markdown-editor.tsx | 3 + 79 files changed, 3725 insertions(+), 1449 deletions(-) create mode 100644 CONTEXT.md create mode 100644 e2e/note-helpers.ts create mode 100644 e2e/note.spec.ts create mode 100644 e2e/person-helpers.ts create mode 100644 e2e/person.spec.ts create mode 100644 e2e/reminder-helpers.ts create mode 100644 e2e/reminder.spec.ts create mode 100644 e2e/setup-helpers.ts create mode 100644 playwright.config.ts create mode 100644 src/app/features/people/lib/person-sharing.test.ts create mode 100644 src/shared/lib/test-ids.ts create mode 100644 src/shared/tools/define-tool.test.ts create mode 100644 src/shared/tools/define-tool.ts create mode 100644 src/shared/tools/note-create.test.ts create mode 100644 src/shared/tools/note-read.test.ts create mode 100644 src/shared/tools/note-update.test.ts create mode 100644 src/shared/tools/person-create.test.ts create mode 100644 src/shared/tools/person-read.test.ts create mode 100644 src/shared/tools/person-update.test.ts create mode 100644 src/shared/tools/reminder-create.test.ts create mode 100644 src/shared/tools/reminder-read.test.ts create mode 100644 src/shared/tools/reminder-update.test.ts create mode 100644 src/shared/tools/test-helpers.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..dc85f941 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,22 @@ +# Tilly — Domain Glossary + +Vocabulary for navigating the codebase. Add terms here when a new concept earns a module of its own. + +## Domain + +- **Person** — central entity. Has `name`, optional `summary` (free-text, may contain hashtags = lists), `avatar`, and child collections of notes and reminders. Soft-deletable via `deletedAt`. (`src/shared/schema/user.ts`) +- **Note** — free-text entry attached to a Person. Optional title, content, images, `pinned`. Soft-deletable. Lives in `Person.notes` (active) or `Person.inactiveNotes` (kept for restore). +- **Reminder** — dated task attached to a Person. `text`, `dueAtDate`, optional `repeat` (interval + day/week/month/year), `done`. Soft-deletable. Same active/inactive split as notes. +- **List** — a hashtag in a Person's `summary`. Lists are not modeled as a separate CoValue; they are derived by parsing hashtags from `summary`. Adding a person to a list = inserting `#tag` into their summary. (Currently scattered across `people/lib`; see ADR-0001's deepening goals.) +- **Sharing** — a Person can be shared via an invite link. Sharing migrates the Person and its child notes/reminders into a new Group so the recipient has access. (`src/app/features/people/lib/person-sharing.ts`) +- **Assistant** — chat-driven AI that mutates the user's data via tools. Conversation state is persisted as `Assistant.stringifiedMessages` on the user account. + +## Architecture + +- **Module** — anything with an interface and an implementation: a function, a file, a feature folder. +- **Operation** — a mutation result returned by a core op. Shape: `{ operation: "create" | "update", current, previous? }`. The `current`/`previous` snapshots use the same shape as the AI tool's success output, so action-layer undo and chat-side undo read the same fields. +- **Deleted** — distinct mutation result for hard deletes. Shape: `{ operation: "delete", previous }`. No `current`, since the entity no longer exists. +- **Core op** — a function that performs a mutation against the Jazz schema and returns `Operation` or `Deleted`. Lives in `src/shared/tools/-.ts`. Takes a `worker: Loaded` and reloads its dependencies fresh. Called directly by both the AI tool adapter and the UI action layer. +- **Tool** — an AI SDK tool, i.e. a description + zod input/output schemas the model uses to interact with the app. Defined per-entity via the `defineTool` primitive (`src/shared/tools/define-tool.ts`). +- **Action handler** — UI-side function (`src/app/features//lib/*-actions.ts`) that calls a core op and adds toast feedback + undo. +- **Adapter** — concrete thing satisfying an interface at a seam. The `defineTool` primitive is the adapter at the AI SDK ↔ core op seam. diff --git a/bun.lock b/bun.lock index c1f959a7..575ff796 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "@clack/prompts": "^1.3.0", "@eslint/js": "9", "@fontsource/inter": "^5.2.8", + "@playwright/test": "^1.59.1", "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-router-devtools": "^1.166.13", @@ -721,6 +722,8 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -2423,6 +2426,10 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "portless": ["portless@0.12.0", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-zE8EYJ5xIEtKEBu1wnD+FavOE90/GNxRixF4Mu2hxMvDuVx1ftUbA6VbjbxI9sP9LUt0L26l6OiENGo4EFtLfw=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -3443,6 +3450,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=="], + "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], diff --git a/e2e/note-helpers.ts b/e2e/note-helpers.ts new file mode 100644 index 00000000..4bdb33e3 --- /dev/null +++ b/e2e/note-helpers.ts @@ -0,0 +1,102 @@ +import { expect, type Page } from "@playwright/test" +import { testIds } from "../src/shared/lib/test-ids" + +export { createNote, listNotes, editNote, deleteNote, restoreNote, togglePin } + +async function createNote( + page: Page, + args: { personId: string; content: string }, +) { + await page.goto(`/app/people/${args.personId}?tab=notes`) + await page.getByTestId(testIds.note.newButton).click() + await page.getByTestId(testIds.note.formContentInput).fill(args.content) + await page.getByTestId(testIds.note.formSubmit).click() + + await expect + .poll(() => page.getByTestId(testIds.note.formSubmit).isVisible()) + .toBe(false) + + await expect + .poll(async () => await page.getByTestId(testIds.note.listItem).count(), { + timeout: 10_000, + }) + .toBeGreaterThan(0) + + let items = await listNotes(page, { personId: args.personId }) + let created = items.items[0] + if (!created) throw new Error("Note not visible after create") + + return { ok: true as const, id: created.id, content: args.content } +} + +async function listNotes(page: Page, args: { personId: string }) { + if (!page.url().includes(`/app/people/${args.personId}`)) { + await page.goto(`/app/people/${args.personId}?tab=notes`) + } + let items = await page.getByTestId(testIds.note.listItem).evaluateAll(rows => + rows.map(row => ({ + id: row.getAttribute("data-note-id") ?? "", + status: row.getAttribute("data-note-status") ?? "", + })), + ) + return { ok: true as const, count: items.length, items } +} + +async function editNote( + page: Page, + args: { personId: string; noteId: string; content: string }, +) { + let row = page.locator( + `[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.note.editButton).click() + await page.getByTestId(testIds.note.formContentInput).fill(args.content) + await page.getByTestId(testIds.note.formSubmit).click() + + await expect + .poll(() => page.getByTestId(testIds.note.formSubmit).isVisible()) + .toBe(false) + + return { ok: true as const, id: args.noteId, content: args.content } +} + +async function togglePin( + page: Page, + args: { personId: string; noteId: string }, +) { + let row = page.locator( + `[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.note.pinButton).click() + return { ok: true as const, id: args.noteId } +} + +async function deleteNote( + page: Page, + args: { personId: string; noteId: string }, +) { + let row = page.locator( + `[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.note.deleteButton).click() + return { ok: true as const, id: args.noteId } +} + +async function restoreNote( + page: Page, + args: { personId: string; noteId: string }, +) { + let row = page.locator( + `[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.note.restoreButton).click() + return { ok: true as const, id: args.noteId } +} diff --git a/e2e/note.spec.ts b/e2e/note.spec.ts new file mode 100644 index 00000000..eebb281e --- /dev/null +++ b/e2e/note.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test" +import { bootApp, skipTourIfShown, gotoPeople } from "./setup-helpers" +import { createPerson } from "./person-helpers" +import { + createNote, + deleteNote, + editNote, + listNotes, + togglePin, +} from "./note-helpers" + +test("note CRUD on a person: create → edit → pin → delete", async ({ + page, +}) => { + await bootApp(page) + await skipTourIfShown(page) + await gotoPeople(page) + + let suffix = Date.now().toString(36) + let person = await createPerson(page, { name: `Note Owner ${suffix}` }) + + let note = await createNote(page, { + personId: person.id, + content: "first note content", + }) + expect(note.id).toMatch(/^co_/) + + let edited = await editNote(page, { + personId: person.id, + noteId: note.id, + content: "edited content", + }) + expect(edited.content).toBe("edited content") + + await togglePin(page, { personId: person.id, noteId: note.id }) + + let beforeDelete = await listNotes(page, { personId: person.id }) + expect( + beforeDelete.items.some(n => n.id === note.id && n.status === "active"), + ).toBe(true) + + await deleteNote(page, { personId: person.id, noteId: note.id }) + + let afterDelete = await listNotes(page, { personId: person.id }) + let stillActive = afterDelete.items.find( + n => n.id === note.id && n.status === "active", + ) + expect(stillActive).toBeUndefined() +}) diff --git a/e2e/person-helpers.ts b/e2e/person-helpers.ts new file mode 100644 index 00000000..39ed5798 --- /dev/null +++ b/e2e/person-helpers.ts @@ -0,0 +1,105 @@ +import { expect, type Page } from "@playwright/test" +import { testIds } from "../src/shared/lib/test-ids" + +export { + createPerson, + listPeople, + openPerson, + updatePerson, + deletePerson, + restorePerson, +} + +async function createPerson( + page: Page, + args: { name: string; summary?: string }, +) { + await page.getByTestId(testIds.person.newButton).first().click() + await page.getByTestId(testIds.person.formNameInput).fill(args.name) + if (args.summary !== undefined) { + await page.getByTestId(testIds.person.formSummaryInput).fill(args.summary) + } + await page.getByTestId(testIds.person.formSubmit).click() + + await expect.poll(() => page.url()).toMatch(/\/people\/co_[^/]+/) + + let id = parsePersonIdFromUrl(page.url()) + if (!id) throw new Error(`Could not parse person id from ${page.url()}`) + + return { ok: true as const, id, name: args.name, summary: args.summary } +} + +async function listPeople(page: Page) { + let items = await page + .getByTestId(testIds.person.listItem) + .evaluateAll(rows => { + return rows.map(row => ({ + id: row.getAttribute("data-person-id") ?? "", + status: row.getAttribute("data-person-status") ?? "", + })) + }) + + return { ok: true as const, count: items.length, items } +} + +async function openPerson(page: Page, args: { personId: string }) { + await page.goto(`/app/people/${args.personId}`) + await expect(page.getByTestId(testIds.person.detailName)).toBeVisible() + + let name = await page.getByTestId(testIds.person.detailName).innerText() + return { ok: true as const, id: args.personId, name } +} + +async function updatePerson( + page: Page, + args: { personId: string; name?: string; summary?: string }, +) { + await page.goto(`/app/people/${args.personId}`) + await page.getByTestId(testIds.person.actionsTrigger).click() + await page.getByTestId(testIds.person.editButton).click() + + if (args.name !== undefined) { + await page.getByTestId(testIds.person.formNameInput).fill(args.name) + } + if (args.summary !== undefined) { + await page.getByTestId(testIds.person.formSummaryInput).fill(args.summary) + } + await page.getByTestId(testIds.person.formSubmit).click() + + await expect + .poll(() => page.getByTestId(testIds.person.formSubmit).isVisible()) + .toBe(false) + + return { ok: true as const, id: args.personId } +} + +async function deletePerson(page: Page, args: { personId: string }) { + await page.goto(`/app/people/${args.personId}`) + await page.getByTestId(testIds.person.actionsTrigger).click() + await page.getByTestId(testIds.person.deleteButton).click() + await page.getByTestId(testIds.person.deleteConfirmButton).click() + + return { ok: true as const, id: args.personId } +} + +async function restorePerson(page: Page, args: { personId: string }) { + await page.goto("/app/people") + let row = page.locator( + `[data-testid="${testIds.person.listItem}"][data-person-id="${args.personId}"]`, + ) + let visible = await row.isVisible({ timeout: 2_000 }).catch(() => false) + if (!visible) { + throw new Error( + `Deleted person ${args.personId} not visible — is the deleted-filter active?`, + ) + } + await row.click() + await page.getByTestId(testIds.person.restoreButton).click() + + return { ok: true as const, id: args.personId } +} + +function parsePersonIdFromUrl(url: string) { + let match = url.match(/\/people\/(co_[^/?#]+)/) + return match?.[1] ?? null +} diff --git a/e2e/person.spec.ts b/e2e/person.spec.ts new file mode 100644 index 00000000..eef3a3c9 --- /dev/null +++ b/e2e/person.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test" +import { bootApp, skipTourIfShown, gotoPeople } from "./setup-helpers" +import { + createPerson, + deletePerson, + listPeople, + openPerson, + updatePerson, +} from "./person-helpers" + +test("person CRUD: create → read → update → delete → restore", async ({ + page, +}) => { + await bootApp(page) + await skipTourIfShown(page) + await gotoPeople(page) + + let suffix = Date.now().toString(36) + let created = await createPerson(page, { + name: `E2E Person ${suffix}`, + summary: "test subject", + }) + expect(created.id).toMatch(/^co_/) + + await gotoPeople(page) + let listed = await listPeople(page) + expect( + listed.items.some(p => p.id === created.id && p.status === "active"), + ).toBe(true) + + let read = await openPerson(page, { personId: created.id }) + expect(read.name).toBe(`E2E Person ${suffix}`) + + let newName = `E2E Person ${suffix} updated` + await updatePerson(page, { personId: created.id, name: newName }) + + let reread = await openPerson(page, { personId: created.id }) + expect(reread.name).toBe(newName) + + await deletePerson(page, { personId: created.id }) + await gotoPeople(page) + let afterDelete = await listPeople(page) + let foundActive = afterDelete.items.find( + p => p.id === created.id && p.status === "active", + ) + expect(foundActive).toBeUndefined() +}) diff --git a/e2e/reminder-helpers.ts b/e2e/reminder-helpers.ts new file mode 100644 index 00000000..457169fc --- /dev/null +++ b/e2e/reminder-helpers.ts @@ -0,0 +1,121 @@ +import { expect, type Page } from "@playwright/test" +import { testIds } from "../src/shared/lib/test-ids" + +export { + createReminder, + listReminders, + editReminder, + markReminderDone, + deleteReminder, + restoreReminder, +} + +async function createReminder( + page: Page, + args: { personId: string; text: string; dueAtDate: string }, +) { + await page.goto(`/app/people/${args.personId}?tab=reminders`) + await page.getByTestId(testIds.reminder.newButton).click() + await page.getByTestId(testIds.reminder.formTextInput).fill(args.text) + await page.getByTestId(testIds.reminder.formDueInput).fill(args.dueAtDate) + await page.getByTestId(testIds.reminder.formSubmit).click() + + await expect + .poll(() => page.getByTestId(testIds.reminder.formSubmit).isVisible()) + .toBe(false) + + await expect + .poll(async () => page.getByTestId(testIds.reminder.listItem).count(), { + timeout: 10_000, + }) + .toBeGreaterThan(0) + + let items = await listReminders(page, { personId: args.personId }) + let created = items.items[0] + if (!created) throw new Error("Reminder not visible after create") + + return { ok: true as const, id: created.id, text: args.text } +} + +async function listReminders(page: Page, args: { personId: string }) { + if (!page.url().includes(`/app/people/${args.personId}`)) { + await page.goto(`/app/people/${args.personId}?tab=reminders`) + } + let items = await page + .getByTestId(testIds.reminder.listItem) + .evaluateAll(rows => + rows.map(row => ({ + id: row.getAttribute("data-reminder-id") ?? "", + status: row.getAttribute("data-reminder-status") ?? "", + })), + ) + return { ok: true as const, count: items.length, items } +} + +async function editReminder( + page: Page, + args: { + personId: string + reminderId: string + text?: string + dueAtDate?: string + }, +) { + let row = page.locator( + `[data-testid="${testIds.reminder.listItem}"][data-reminder-id="${args.reminderId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.reminder.editButton).click() + if (args.text !== undefined) { + await page.getByTestId(testIds.reminder.formTextInput).fill(args.text) + } + if (args.dueAtDate !== undefined) { + await page.getByTestId(testIds.reminder.formDueInput).fill(args.dueAtDate) + } + await page.getByTestId(testIds.reminder.formSubmit).click() + await expect + .poll(() => page.getByTestId(testIds.reminder.formSubmit).isVisible()) + .toBe(false) + + return { ok: true as const, id: args.reminderId } +} + +async function markReminderDone( + page: Page, + args: { personId: string; reminderId: string }, +) { + let row = page.locator( + `[data-testid="${testIds.reminder.listItem}"][data-reminder-id="${args.reminderId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.reminder.doneToggle).click() + return { ok: true as const, id: args.reminderId } +} + +async function deleteReminder( + page: Page, + args: { personId: string; reminderId: string }, +) { + let row = page.locator( + `[data-testid="${testIds.reminder.listItem}"][data-reminder-id="${args.reminderId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.reminder.deleteButton).click() + return { ok: true as const, id: args.reminderId } +} + +async function restoreReminder( + page: Page, + args: { personId: string; reminderId: string }, +) { + let row = page.locator( + `[data-testid="${testIds.reminder.listItem}"][data-reminder-id="${args.reminderId}"]`, + ) + await row.scrollIntoViewIfNeeded() + await row.click() + await row.getByTestId(testIds.reminder.restoreButton).click() + return { ok: true as const, id: args.reminderId } +} diff --git a/e2e/reminder.spec.ts b/e2e/reminder.spec.ts new file mode 100644 index 00000000..d72acecd --- /dev/null +++ b/e2e/reminder.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "@playwright/test" +import { bootApp, skipTourIfShown, gotoPeople } from "./setup-helpers" +import { createPerson } from "./person-helpers" +import { + createReminder, + deleteReminder, + editReminder, + listReminders, + markReminderDone, +} from "./reminder-helpers" + +test("reminder CRUD on a person: create → edit → delete", async ({ page }) => { + await bootApp(page) + await skipTourIfShown(page) + await gotoPeople(page) + + let suffix = Date.now().toString(36) + let person = await createPerson(page, { name: `Reminder Owner ${suffix}` }) + + let tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + let due = tomorrow.toISOString().slice(0, 10) + + let reminder = await createReminder(page, { + personId: person.id, + text: "call back later", + dueAtDate: due, + }) + expect(reminder.id).toMatch(/^co_/) + + await editReminder(page, { + personId: person.id, + reminderId: reminder.id, + text: "call back tomorrow", + }) + + let active = await listReminders(page, { personId: person.id }) + expect( + active.items.some(r => r.id === reminder.id && r.status === "active"), + ).toBe(true) + + await deleteReminder(page, { + personId: person.id, + reminderId: reminder.id, + }) + + let final = await listReminders(page, { personId: person.id }) + let stillActive = final.items.find( + r => r.id === reminder.id && r.status === "active", + ) + expect(stillActive).toBeUndefined() +}) + +test("reminder mark done: create → mark done removes from active", async ({ + page, +}) => { + await bootApp(page) + await skipTourIfShown(page) + await gotoPeople(page) + + let suffix = Date.now().toString(36) + let person = await createPerson(page, { + name: `Reminder DoneOwner ${suffix}`, + }) + + let tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + let due = tomorrow.toISOString().slice(0, 10) + + let reminder = await createReminder(page, { + personId: person.id, + text: "do the thing", + dueAtDate: due, + }) + + await markReminderDone(page, { + personId: person.id, + reminderId: reminder.id, + }) + + let final = await listReminders(page, { personId: person.id }) + let stillActive = final.items.find( + r => r.id === reminder.id && r.status === "active", + ) + expect(stillActive).toBeUndefined() +}) diff --git a/e2e/setup-helpers.ts b/e2e/setup-helpers.ts new file mode 100644 index 00000000..1d9ea614 --- /dev/null +++ b/e2e/setup-helpers.ts @@ -0,0 +1,22 @@ +import { type Page } from "@playwright/test" +import { testIds } from "../src/shared/lib/test-ids" + +export { bootApp, skipTourIfShown, gotoPeople } + +async function bootApp(page: Page) { + await page.goto("/app/") + await page.waitForLoadState("networkidle") +} + +async function skipTourIfShown(page: Page) { + let skip = page.getByTestId(testIds.tour.skipButton) + if (await skip.isVisible({ timeout: 2_000 }).catch(() => false)) { + await skip.click() + await page.waitForLoadState("networkidle") + } +} + +async function gotoPeople(page: Page) { + await page.goto("/app/people") + await page.waitForLoadState("networkidle") +} diff --git a/eslint.config.js b/eslint.config.js index 28068960..aaeca324 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -198,6 +198,16 @@ export default [ plugins: { "@typescript-eslint": tseslint }, rules: { ...tseslint.configs.recommended.rules, ...commonRules }, }, + { + files: ["e2e/**/*.ts"], + languageOptions: { + parser: tsparser, + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + globals: globals.node, + }, + plugins: { "@typescript-eslint": tseslint }, + rules: { ...tseslint.configs.recommended.rules, ...commonRules }, + }, { ignores: [ "**/dist/", diff --git a/package.json b/package.json index 034bee30..65c5db7c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix", "test": "vitest", "test:run": "vitest run", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", "knip": "knip --no-config-hints", "knip:fix": "knip --fix --no-config-hints" }, @@ -72,6 +74,7 @@ "@clack/prompts": "^1.3.0", "@eslint/js": "9", "@fontsource/inter": "^5.2.8", + "@playwright/test": "^1.59.1", "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-router-devtools": "^1.166.13", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..83c7ceb2 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test" + +let appUrl = "https://tilly.localhost" + +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 2 : 0, + reporter: "list", + use: { + baseURL: appUrl, + ignoreHTTPSErrors: true, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/src/app/features/assistant/parts/note-create-result.tsx b/src/app/features/assistant/parts/note-create-result.tsx index 130bd646..cf028a56 100644 --- a/src/app/features/assistant/parts/note-create-result.tsx +++ b/src/app/features/assistant/parts/note-create-result.tsx @@ -44,14 +44,11 @@ function NoteCreateResult({ setIsUndoing(true) setDrawerOpen(false) try { - updateNote( - { deletedAt: new Date() }, - { - personId: result.personId, - noteId: result.noteId, - worker: me, - }, - ) + updateNote(me, { + personId: result.current.personId, + noteId: result.current.noteId, + deletedAt: new Date(), + }) setIsUndone(true) addMessage({ id: `undo-${nanoid()}`, @@ -100,7 +97,7 @@ function NoteCreateResult({ 50 ? "..." : ""}"`, + content: `"${result.current.content.substring(0, 50)}${result.current.content.length > 50 ? "..." : ""}"`, }} /> } @@ -118,8 +115,10 @@ function NoteCreateResult({

-

{result.content}

- {result.pinned && ( +

+ {result.current.content} +

+ {result.current.pinned && (

@@ -129,9 +128,9 @@ function NoteCreateResult({ - - - @@ -82,7 +83,7 @@ function NoActivePeopleState({ + } diff --git a/src/app/features/people/screens/people-screen.tsx b/src/app/features/people/screens/people-screen.tsx index f013eab5..592f402e 100644 --- a/src/app/features/people/screens/people-screen.tsx +++ b/src/app/features/people/screens/people-screen.tsx @@ -27,6 +27,7 @@ import { HugeiconsIcon } from "@hugeicons/react" import { Add01Icon } from "@hugeicons/core-free-icons" import { Button } from "#shared/ui/button" import { T } from "#shared/intl/setup" +import { testIds } from "#shared/lib/test-ids" type PeopleScreenProps = { fallback: Parameters[0] @@ -139,7 +140,7 @@ export function PeopleScreen({ fallback }: PeopleScreenProps) { + {person.summary && ( -

+

{person.summary.split(/(#[a-zA-Z0-9_]+)/).map((part, i) => part.startsWith("#") ? ( diff --git a/src/app/features/people/widgets/person-form.tsx b/src/app/features/people/widgets/person-form.tsx index 467e8b66..f04b2f8d 100644 --- a/src/app/features/people/widgets/person-form.tsx +++ b/src/app/features/people/widgets/person-form.tsx @@ -26,6 +26,7 @@ import { import { AvatarField } from "../parts/avatar-field" import { AvatarCropperDialog } from "../parts/avatar-cropper-dialog" import { getCroppedImg } from "../lib/image-crop" +import { testIds } from "#shared/lib/test-ids" export { PersonForm } @@ -121,6 +122,7 @@ function PersonForm({ @@ -142,6 +144,7 @@ function PersonForm({ placeholder={t("person.form.summary.placeholder")} rows={4} onKeyDown={submitOnCtrlEnter} + data-testid={testIds.person.formSummaryInput} {...field} /> @@ -155,7 +158,11 @@ function PersonForm({ +