From 35dac718bcfd7ed0544a70b22b9aec51d65dec64 Mon Sep 17 00:00:00 2001 From: Ben Zwick Date: Tue, 26 May 2026 11:55:48 +0800 Subject: [PATCH 1/2] Add data-testid hooks for end-to-end testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI is styled entirely with inline styles and exposes almost no stable selectors: icon-only buttons (member remove, color reset, schedule nav) and dozens of anonymous range sliders share no distinguishing text or role. To let an end-to-end suite target controls reliably — without coupling tests to copy or fragile DOM structure — add data-testid attributes to the controls that lack a stable accessible name. The additions are purely non-visual: the Slider primitive grows an optional testId prop applied to its range input, and call sites key their testids off existing entity ids (member / category / task) so they stay unique as lists grow and reorder. Controls that already carry an aria-label or visible text (tabs, header buttons, add/demo/clear, schedule view switch) are left as-is. --- src/App.jsx | 93 ++++++++++++++++++++++------------- src/import/CsvImportModal.jsx | 3 ++ 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index bb61d71..4ae6a0f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -605,7 +605,7 @@ function CustomizePanel({ onClose }) { const fontPreview = { head: "Aa Heading", body: "Body sentence.", mono: "mono 0123" }; const fontSlotLabel = { head: "Heading", body: "Body", mono: "Mono" }; return ( -
THEME
setSlot(slot.key, e.target.value)} style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer", border: "none" }} @@ -673,7 +675,7 @@ function CustomizePanel({ onClose }) {
{slot.sub} · {current}
{isOverridden && ( - )} @@ -695,6 +697,7 @@ function CustomizePanel({ onClose }) { {fontSlotLabel[slot]} onChange(Number(e.target.value))} style={{ width: "100%", height: 4, borderRadius: 999, appearance: "none", @@ -1401,7 +1405,7 @@ function Legend() {
- +
@@ -1473,6 +1478,7 @@ function TeamView({ state, update, addMember, removeMember, summary }) { value={m.capacity} onChange={(v) => update(st => { st.members.find(x => x.id === m.id).capacity = v; return st; })} min={-3} max={3} color={colors.teal} label={capacityWord(m.capacity)} + testId={`member-capacity-${m.id}`} />
@@ -1529,6 +1535,7 @@ function MemberBaselines({ member, categories, update }) {
); @@ -1593,6 +1600,7 @@ function MemberAvailability({ member, update }) {
{open && (
- {DAYS.map(d => { @@ -1623,6 +1631,7 @@ function MemberAvailability({ member, update }) { { const next = ranges.map((x, j) => j === i ? { ...x, from: e.target.value } : x); @@ -1638,6 +1647,7 @@ function MemberAvailability({ member, update }) { { const next = ranges.map((x, j) => j === i ? { ...x, to: e.target.value } : x); @@ -1654,6 +1664,7 @@ function MemberAvailability({ member, update }) { onClick={() => setDay(d, ranges.filter((_, j) => j !== i))} style={{ ...btnIcon, width: 18, height: 18 }} title="remove range" + data-testid={`avail-${member.id}-${d}-remove-${i}`} > @@ -1669,6 +1680,7 @@ function MemberAvailability({ member, update }) { }} style={{ ...btnGhost, fontSize: 10, padding: "2px 6px", marginLeft: "auto" }} title="add range" + data-testid={`avail-${member.id}-${d}-add`} > + range @@ -1707,6 +1719,7 @@ function MemberWindows({ member, update }) {
); @@ -1799,11 +1814,12 @@ function TasksView({ state, update, addTask, removeTask, editing, setEditing, as const assignee = a ? state.members.find(m => m.id === a.memberId) : null; const q = quadrantOf(t); return ( -
+
update(st => { st.tasks.find(x => x.id === t.id).title = e.target.value; return st; })} style={{ ...inputBare, fontFamily: "var(--joy-font-head)", fontSize: isPhone ? 16 : 18, fontWeight: 600, letterSpacing: "-0.01em", padding: 0 }} /> @@ -1820,11 +1836,12 @@ function TasksView({ state, update, addTask, removeTask, editing, setEditing, as style={isPhone ? btnIcon : btnGhost} title={isOpen ? "Close editor" : "Edit task"} aria-label={isOpen ? "Close editor" : "Edit task"} + data-testid={`task-edit-${t.id}`} > {isPhone ? (isOpen ? : "✎") : (isOpen ? "close" : "edit")} - {!isPhone && } - {isPhone && !isOpen && } + {!isPhone && } + {isPhone && !isOpen && }
@@ -1842,6 +1859,7 @@ function TasksView({ state, update, addTask, removeTask, editing, setEditing, as
CATEGORY update(st => { st.tasks.find(x => x.id === t.id).stakeholderId = e.target.value || null; return st; })} style={{ @@ -1886,9 +1905,9 @@ function TasksView({ state, update, addTask, removeTask, editing, setEditing, as onChange={(next) => update(st => { st.tasks.find(x => x.id === t.id).dueDate = next; return st; })} />
- update(st => { st.tasks.find(x => x.id === t.id).urgency = v; return st; })} min={1} max={5} color={colors.rustDeep} label="urgency" /> - update(st => { st.tasks.find(x => x.id === t.id).importance = v; return st; })} min={1} max={5} color={colors.teal} label="importance" /> - update(st => { st.tasks.find(x => x.id === t.id).effort = v; return st; })} min={1} max={5} color={colors.ochre} label="effort" /> + update(st => { st.tasks.find(x => x.id === t.id).urgency = v; return st; })} min={1} max={5} color={colors.rustDeep} label="urgency" testId="task-urgency" /> + update(st => { st.tasks.find(x => x.id === t.id).importance = v; return st; })} min={1} max={5} color={colors.teal} label="importance" testId="task-importance" /> + update(st => { st.tasks.find(x => x.id === t.id).effort = v; return st; })} min={1} max={5} color={colors.ochre} label="effort" testId="task-effort" />
{state.members.length > 0 && ( @@ -1912,15 +1931,15 @@ function TasksView({ state, update, addTask, removeTask, editing, setEditing, as
{m.name}
{isPhone ? (
- setField("pleasure", v)} min={-3} max={3} color={colors.rust} label={ pleasure} /> - setField("talent", v)} min={-3} max={3} color={colors.teal} label={ talent} /> - setField("difficulty", v)} min={1} max={5} color={colors.ochre} label={ difficulty} /> + setField("pleasure", v)} min={-3} max={3} color={colors.rust} label={ pleasure} testId={`task-fit-${m.id}-pleasure`} /> + setField("talent", v)} min={-3} max={3} color={colors.teal} label={ talent} testId={`task-fit-${m.id}-talent`} /> + setField("difficulty", v)} min={1} max={5} color={colors.ochre} label={ difficulty} testId={`task-fit-${m.id}-difficulty`} />
) : ( <> - setField("pleasure", v)} min={-3} max={3} color={colors.rust} label={ pleasure} /> - setField("talent", v)} min={-3} max={3} color={colors.teal} label={ talent} /> - setField("difficulty", v)} min={1} max={5} color={colors.ochre} label={ difficulty} /> + setField("pleasure", v)} min={-3} max={3} color={colors.rust} label={ pleasure} testId={`task-fit-${m.id}-pleasure`} /> + setField("talent", v)} min={-3} max={3} color={colors.teal} label={ talent} testId={`task-fit-${m.id}-talent`} /> + setField("difficulty", v)} min={1} max={5} color={colors.ochre} label={ difficulty} testId={`task-fit-${m.id}-difficulty`} /> )}
@@ -2019,6 +2038,7 @@ function ScheduleViewSwitcher({ value, onChange }) { {opts.map(([k, label]) => ( -
+ +
{day.toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" })}
- +
{members.length === 0 && Nothing scheduled this day.} {members.length > 0 && ( @@ -2206,9 +2226,9 @@ function ScheduleMonthView({ state, schedule, allBlocks }) { return (
- -
{monthName}
- + +
{monthName}
+
@@ -2685,25 +2705,25 @@ function CategoriesBar({ state, update }) { )} {categories.map(c => ( - - - ))} -
@@ -2750,25 +2770,25 @@ function StakeholdersBar({ state, update }) { )} {stakeholders.map(x => ( - - - ))} - +
); } @@ -2816,6 +2836,7 @@ function DueDatePicker({ value, onChange }) { ].map(([key, label]) => (
{mode === "fuzzy" && ( { // datetime-local wants "YYYY-MM-DDTHH:mm" without timezone const d = new Date(value.value); diff --git a/src/import/CsvImportModal.jsx b/src/import/CsvImportModal.jsx index 905b3b8..a8782d7 100644 --- a/src/import/CsvImportModal.jsx +++ b/src/import/CsvImportModal.jsx @@ -95,6 +95,7 @@ export default function CsvImportModal({ rawText, filename, project, onClose, on }} onClick={onClose}>
e.stopPropagation()} + data-testid="csv-import-modal" style={{ width: "min(720px, 100%)", background: COLORS.bone, @@ -146,6 +147,7 @@ export default function CsvImportModal({ rawText, filename, project, onClose, on {f.label}{f.required && *}
in the Settings panel. It is the first combobox +// rendered there ("Language Model"); on the default matrix tab no other +// to a target value the way a keyboard user +// would: focus, jump to min with Home, then step up with ArrowRight. Falls +// back to a native value-setter + input event (React-compatible) if the +// keyboard path doesn't land exactly. +export async function setRange(locator: Locator, value: number): Promise { + const min = Number((await locator.getAttribute("min")) ?? 0); + const max = Number((await locator.getAttribute("max")) ?? 100); + const step = Number((await locator.getAttribute("step")) || 1); + const target = Math.min(max, Math.max(min, value)); + + await locator.focus(); + await locator.press("Home"); + let cur = min; + let guard = 0; + while (cur < target && guard < 200) { + await locator.press("ArrowRight"); + cur += step; + guard++; + } + + if (Number(await locator.inputValue()) !== target) { + await locator.evaluate((el: HTMLInputElement, v: number) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )!.set!; + setter.call(el, String(v)); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }, target); + } + + await expect(locator).toHaveValue(String(target)); +} diff --git a/tests/e2e/specs/categories-stakeholders.spec.ts b/tests/e2e/specs/categories-stakeholders.spec.ts new file mode 100644 index 0000000..7df03de --- /dev/null +++ b/tests/e2e/specs/categories-stakeholders.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "../fixtures/base"; +import { acceptNextPrompt, acceptNextConfirm } from "../fixtures/dialogs"; +import { SCHEDULE_STATE } from "../data/seeds"; + +test.describe("@core categories & stakeholders", () => { + test.use({ seed: SCHEDULE_STATE }); + + test.beforeEach(async ({ app }) => { + await app.getByRole("button", { name: "Tasks", exact: true }).click(); + }); + + test("adds, renames and removes a category", async ({ app }) => { + acceptNextPrompt(app, "Marketing"); + await app.getByTestId("add-category").click(); + await expect(app.getByRole("button", { name: "Marketing" })).toBeVisible(); + + acceptNextPrompt(app, "Eng & infra"); + await app.getByTestId("category-rename-c-eng").click(); + await expect(app.getByRole("button", { name: "Eng & infra" })).toBeVisible(); + + acceptNextConfirm(app); + await app.getByTestId("category-remove-c-eng").click(); + await expect(app.getByTestId("category-chip-c-eng")).toBeHidden(); + }); + + test("adds, renames and removes a stakeholder", async ({ app }) => { + acceptNextPrompt(app, "Investors"); + await app.getByTestId("add-stakeholder").click(); + await expect(app.getByRole("button", { name: "Investors" })).toBeVisible(); + + acceptNextPrompt(app, "Power users"); + await app.getByTestId("stakeholder-rename-s-users").click(); + await expect(app.getByRole("button", { name: "Power users" })).toBeVisible(); + + acceptNextConfirm(app); + await app.getByTestId("stakeholder-remove-s-users").click(); + await expect(app.getByTestId("stakeholder-chip-s-users")).toBeHidden(); + }); +}); diff --git a/tests/e2e/specs/csv-import.spec.ts b/tests/e2e/specs/csv-import.spec.ts new file mode 100644 index 0000000..49b9ee2 --- /dev/null +++ b/tests/e2e/specs/csv-import.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "../fixtures/base"; +import { importText } from "../helpers/files"; + +const CSV = [ + "Title,Urgency,Importance,Effort,Due,Assignee,Category", + "Write the docs,High,High,2,this-week,Dana,Docs", + "Fix the bug,Low,Medium,1,tomorrow,,Bugs", +].join("\n"); + +test.describe("@core csv import", () => { + test.use({ seed: "empty" }); + + test("opens the mapping modal and imports tasks", async ({ app }) => { + await importText(app, "tasks.csv", CSV, "text/csv"); + + const modal = app.getByTestId("csv-import-modal"); + await expect(modal).toBeVisible(); + await expect(modal.getByText("Import from CSV")).toBeVisible(); + // Auto-detected the Title column, so the preview lists drafted tasks. + await expect(modal.getByText("Write the docs")).toBeVisible(); + + await app.getByTestId("csv-commit").click(); + await expect(modal).toBeHidden(); + + await app.getByRole("button", { name: "Tasks", exact: true }).click(); + const titles = app.locator('[data-testid^="task-title-"]'); + await expect(titles).toHaveCount(2); + await expect(titles.nth(0)).toHaveValue("Write the docs"); + await expect(titles.nth(1)).toHaveValue("Fix the bug"); + }); + + test("cancel closes the modal without importing", async ({ app }) => { + await importText(app, "tasks.csv", CSV, "text/csv"); + await expect(app.getByTestId("csv-import-modal")).toBeVisible(); + await app.getByRole("button", { name: "Cancel" }).click(); + await expect(app.getByTestId("csv-import-modal")).toBeHidden(); + + await app.getByRole("button", { name: "Tasks", exact: true }).click(); + await expect(app.getByText("No tasks yet.")).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/customize.spec.ts b/tests/e2e/specs/customize.spec.ts new file mode 100644 index 0000000..2660b69 --- /dev/null +++ b/tests/e2e/specs/customize.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, type Page } from "../fixtures/base"; + +const theme = (app: Page) => + app.evaluate(() => JSON.parse(localStorage.getItem("joy-matrix-theme-v1") || "{}")); + +// input[type=color] can't be driven with fill(); set the value natively and +// dispatch input so React's onChange fires (matches a real picker selection). +async function pickColor(app: Page, testId: string, hex: string) { + await app.getByTestId(testId).evaluate((el: HTMLInputElement, v: string) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )!.set!; + setter.call(el, v); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }, hex); +} + +test.describe("@core customize", () => { + test.beforeEach(async ({ app }) => { + await app.getByRole("button", { name: "Customize colors" }).click(); + await expect(app.getByTestId("customize-panel")).toBeVisible(); + }); + + test("switches theme preset", async ({ app }) => { + await app.getByTestId("theme-preset").selectOption("workbook"); + expect((await theme(app)).themeId).toBe("workbook"); + }); + + test("switches light/dark inside the panel", async ({ app }) => { + const panel = app.getByTestId("customize-panel"); + await panel.getByRole("button", { name: "dark" }).click(); + expect((await theme(app)).mode).toBe("dark"); + }); + + test("overrides and resets a color", async ({ app }) => { + await pickColor(app, "color-paper", "#ff0000"); + expect(((await theme(app)).overrides["--joy-paper"] || "").toLowerCase()).toBe("#ff0000"); + + // A reset affordance appears once overridden. + await expect(app.getByTestId("color-reset-paper")).toBeVisible(); + await app.getByTestId("color-reset-paper").click(); + expect((await theme(app)).overrides["--joy-paper"]).toBeUndefined(); + }); + + test("reset-all clears overrides", async ({ app }) => { + await pickColor(app, "color-ink", "#123456"); + await app.getByRole("button", { name: /Reset colors to preset/i }).click(); + expect((await theme(app)).overrides).toEqual({}); + }); + + test("changes a font and resets fonts", async ({ app }) => { + await app.getByTestId("font-head").selectOption("Inter"); + expect((await theme(app)).fonts.head).toBe("Inter"); + await app.getByRole("button", { name: /Reset fonts to default/i }).click(); + expect((await theme(app)).fonts).toEqual({}); + }); + + test("color override survives reload", async ({ app }) => { + await pickColor(app, "color-teal", "#00ffcc"); + await app.reload(); + await app.getByRole("button", { name: "Customize colors" }).click(); + expect(((await theme(app)).overrides["--joy-teal"] || "").toLowerCase()).toBe("#00ffcc"); + await expect(app.getByTestId("color-reset-teal")).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/header-actions.spec.ts b/tests/e2e/specs/header-actions.spec.ts new file mode 100644 index 0000000..ac5f6b7 --- /dev/null +++ b/tests/e2e/specs/header-actions.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "../fixtures/base"; +import { acceptNextConfirm, dismissNextConfirm } from "../fixtures/dialogs"; + +const themeMode = (app: any) => + app.evaluate(() => JSON.parse(localStorage.getItem("joy-matrix-theme-v1") || "{}").mode); + +test.describe("@core header", () => { + test("toggles light/dark and persists the choice", async ({ app }) => { + expect(await themeMode(app)).toBe("light"); + await app.getByRole("button", { name: "Toggle light/dark" }).click(); + expect(await themeMode(app)).toBe("dark"); + // When dark, the button offers the way back to light. + await expect(app.getByRole("button", { name: "Toggle light/dark" })).toContainText("light"); + }); + + test("opens and closes the customize panel", async ({ app }) => { + await app.getByRole("button", { name: "Customize colors" }).click(); + await expect(app.getByTestId("customize-panel")).toBeVisible(); + await app.getByRole("button", { name: "Close customize panel" }).click(); + await expect(app.getByTestId("customize-panel")).toBeHidden(); + }); + + test("goal A→B text persists across reload", async ({ app }) => { + await app.getByPlaceholder("where you are now").fill("scrappy prototype"); + await app.getByPlaceholder("where you want to be").fill("market leader"); + await app.reload(); + await expect(app.getByPlaceholder("where you are now")).toHaveValue("scrappy prototype"); + await expect(app.getByPlaceholder("where you want to be")).toHaveValue("market leader"); + }); +}); + +test.describe("@core header demo/clear", () => { + test.use({ seed: "empty" }); + + test("Demo loads sample data when confirmed", async ({ app }) => { + await expect(app.getByText(/No team members yet/)).toBeHidden(); // matrix tab first + acceptNextConfirm(app); + await app.getByRole("button", { name: "demo" }).click(); + await expect(app.getByText(/Maya/).first()).toBeVisible(); + }); + + test("Demo does nothing when dismissed", async ({ app }) => { + dismissNextConfirm(app); + await app.getByRole("button", { name: "demo" }).click(); + await expect(app.getByText(/Maya/)).toHaveCount(0); + }); +}); + +test.describe("@core header clear", () => { + test("Clear wipes the project when confirmed", async ({ app }) => { + acceptNextConfirm(app); + await app.getByRole("button", { name: "clear" }).click(); + await app.getByRole("button", { name: "Team", exact: true }).click(); + await expect(app.getByText(/No team members yet/)).toBeVisible(); + }); + + test("Clear does nothing when dismissed", async ({ app }) => { + dismissNextConfirm(app); + await app.getByRole("button", { name: "clear" }).click(); + await expect(app.getByText(/Maya/).first()).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/import-export.spec.ts b/tests/e2e/specs/import-export.spec.ts new file mode 100644 index 0000000..054e372 --- /dev/null +++ b/tests/e2e/specs/import-export.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from "../fixtures/base"; +import { acceptNextConfirm, captureNextDialog } from "../fixtures/dialogs"; +import { expectJsonDownload, importJson, importText } from "../helpers/files"; +import { HEALTHY_STATE } from "../data/seeds"; + +test.describe("@core export", () => { + test("exports the demo project as a dated JSON envelope", async ({ app }) => { + const { filename, json } = await expectJsonDownload(app, () => + app.getByRole("button", { name: "Export project" }).click() + ); + // Clock is frozen to 2025-06-02. + expect(filename).toBe("joy-matrix-2025-06-02.json"); + expect(json.app).toBe("joy-matrix"); + expect(json.project.members.length).toBe(3); + }); +}); + +test.describe("@core import", () => { + test.use({ seed: "empty" }); + + test("imports a valid project after confirmation", async ({ app }) => { + acceptNextConfirm(app); + await importJson(app, "project.json", { + app: "joy-matrix", + schemaVersion: 5, + project: HEALTHY_STATE, + }); + await app.getByRole("button", { name: "Team", exact: true }).click(); + // HEALTHY_STATE carries member id m-robin. + await expect(app.getByTestId("member-name-m-robin")).toHaveValue("Robin"); + }); + + test("rejects malformed JSON with an alert", async ({ app }) => { + const msg = captureNextDialog(app); + await importText(app, "broken.json", "not json at all {"); + expect(await msg).toMatch(/isn't valid JSON/i); + }); + + test("rejects a file that isn't a Joy Matrix export", async ({ app }) => { + const msg = captureNextDialog(app); + await importJson(app, "other.json", { app: "something-else", project: {} }); + expect(await msg).toMatch(/Not an export from The Joy Matrix/i); + }); + + test("rejects an export from a newer schema version", async ({ app }) => { + const msg = captureNextDialog(app); + await importJson(app, "future.json", { app: "joy-matrix", schemaVersion: 99, project: {} }); + expect(await msg).toMatch(/newer version/i); + }); +}); diff --git a/tests/e2e/specs/insights.spec.ts b/tests/e2e/specs/insights.spec.ts new file mode 100644 index 0000000..2aedd17 --- /dev/null +++ b/tests/e2e/specs/insights.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "../fixtures/base"; +import { BURNOUT_STATE, HEALTHY_STATE } from "../data/seeds"; + +test.describe("@core insights demo", () => { + test("shows headline stats and per-person load", async ({ app }) => { + await app.getByRole("button", { name: "Insights", exact: true }).click(); + await expect(app.getByRole("heading", { name: "The Reading" })).toBeVisible(); + for (const label of ["Total Joy Index", "Total Talent Fit", "Burnout", "Stretched"]) { + await expect(app.getByText(label, { exact: true })).toBeVisible(); + } + await expect(app.getByText("JOY × LOAD PER PERSON")).toBeVisible(); + await expect(app.getByText("Maya").first()).toBeVisible(); + }); +}); + +test.describe("@core insights burnout", () => { + test.use({ seed: BURNOUT_STATE }); + + test("flags burnout, pain points and the 'Stop' narrative", async ({ app }) => { + await app.getByRole("button", { name: "Insights", exact: true }).click(); + await expect(app.getByText("PAIN POINTS — RECONSIDER")).toBeVisible(); + await expect(app.getByText(/Stop\. Someone is over capacity/)).toBeVisible(); + await expect(app.getByText(/The grind/)).toBeVisible(); + }); +}); + +test.describe("@core insights healthy", () => { + test.use({ seed: HEALTHY_STATE }); + + test("reports a healthy state", async ({ app }) => { + await app.getByRole("button", { name: "Insights", exact: true }).click(); + await expect(app.getByText(/Healthy state/)).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/live/chat-conversation.spec.ts b/tests/e2e/specs/live/chat-conversation.spec.ts new file mode 100644 index 0000000..de8bbdd --- /dev/null +++ b/tests/e2e/specs/live/chat-conversation.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "../../fixtures/base"; +import { liveCreds, openChatAndLogin } from "../../fixtures/live"; + +const creds = liveCreds(); + +test.describe("@live chat conversation", () => { + test.skip(!creds, "requires T2V_TEST_USER_EMAIL / T2V_TEST_USER_PASSWORD"); + test.use({ blockExternal: false, seed: "demo" }); + + test("sends a message and clears the thread", async ({ app }) => { + await openChatAndLogin(app, creds!); + + const composer = app.getByPlaceholder("Type a message…"); + await composer.fill("Reply with the single word: pong."); + await app.getByRole("button", { name: "Send message" }).click(); + + // The user's message is echoed into the thread (welcome screen replaced). + await expect(app.getByText("Reply with the single word: pong.")).toBeVisible(); + // And an assistant reply streams back (generous timeout for the live model). + await expect(app.getByText(/pong/i).last()).toBeVisible({ timeout: 45_000 }); + + // "Clear Chat" (aria-label "New chat") resets to the welcome screen. + await app.getByRole("button", { name: "New chat" }).click(); + await expect(app.getByRole("heading", { name: "Ask The Joy Matrix" })).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/live/chat-login.spec.ts b/tests/e2e/specs/live/chat-login.spec.ts new file mode 100644 index 0000000..c845b3c --- /dev/null +++ b/tests/e2e/specs/live/chat-login.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "../../fixtures/base"; +import { liveCreds, openChatAndLogin } from "../../fixtures/live"; + +const creds = liveCreds(); + +test.describe("@live chat login", () => { + test.skip(!creds, "requires T2V_TEST_USER_EMAIL / T2V_TEST_USER_PASSWORD"); + // Live tier talks to the real Talk2View API — don't block external network. + test.use({ blockExternal: false, seed: "demo" }); + + test("logs in then signs out", async ({ app }) => { + await openChatAndLogin(app, creds!); + await expect(app.getByRole("button", { name: "Send message" })).toBeVisible(); + + await app.getByRole("button", { name: "Menu" }).click(); + await app.getByRole("menuitem", { name: "Sign out" }).click(); + + // Back to the login form. + await expect(app.locator("#t2v-email")).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/live/chat-model-switch.spec.ts b/tests/e2e/specs/live/chat-model-switch.spec.ts new file mode 100644 index 0000000..7021bc5 --- /dev/null +++ b/tests/e2e/specs/live/chat-model-switch.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "../../fixtures/base"; +import { liveCreds, openChatAndLogin, openSettings, modelSelect } from "../../fixtures/live"; + +const creds = liveCreds(); + +test.describe("@live chat model switch", () => { + test.skip(!creds, "requires T2V_TEST_USER_EMAIL / T2V_TEST_USER_PASSWORD"); + test.use({ blockExternal: false, seed: "demo" }); + + test("switches the active model and persists the choice", async ({ app }) => { + await openChatAndLogin(app, creds!); + await openSettings(app); + + const sel = modelSelect(app); + await expect(sel).toBeVisible(); + // Wait for listModels() to populate real options (past "Loading models…"). + await expect + .poll(async () => sel.locator("option").count(), { timeout: 30_000 }) + .toBeGreaterThan(1); + + const before = await sel.inputValue(); + const values = ( + await sel.locator("option").evaluateAll((opts) => + (opts as HTMLOptionElement[]).map((o) => o.value) + ) + ).filter((v) => v && v !== before); + + test.skip(values.length === 0, "only one model available to this account"); + + const next = values[0]; + await sel.selectOption(next); + await expect(sel).toHaveValue(next); + + // Reopen settings — the selection is persisted in user preferences. + await app.getByRole("button", { name: "Back to chat" }).click(); + await openSettings(app); + await expect(modelSelect(app)).toHaveValue(next); + }); +}); diff --git a/tests/e2e/specs/matrix.spec.ts b/tests/e2e/specs/matrix.spec.ts new file mode 100644 index 0000000..9e3829d --- /dev/null +++ b/tests/e2e/specs/matrix.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "../fixtures/base"; + +test.describe("@core matrix", () => { + test("places demo tasks into quadrants with assignees", async ({ app }) => { + await expect(app.getByRole("heading", { name: "The Matrix" })).toBeVisible(); + // Urgent + important task and its auto-assignee. + const doTask = app.getByRole("button", { name: /Ship landing page redesign/ }); + await expect(doTask).toBeVisible(); + await expect(doTask).toContainText("Maya"); + // All four quadrants render (matched by their unique subtitles). + for (const sub of [ + "urgent · important", + "important · not urgent", + "urgent · not important", + "neither — drop or defer", + ]) { + await expect(app.getByText(sub)).toBeVisible(); + } + }); + + test("clicking a task opens its editor on the Tasks tab", async ({ app }) => { + await app.getByRole("button", { name: /Ship landing page redesign/ }).click(); + await expect(app.getByRole("heading", { name: "The Tasks" })).toBeVisible(); + // Editor for t1 is open: per-member fit sliders for demo member m1 (Maya). + await expect(app.getByTestId("task-fit-m1-pleasure")).toBeVisible(); + }); + + test("legend expands and collapses", async ({ app }) => { + await expect(app.getByText(/workflow order, not grid positions/)).toBeHidden(); + await app.getByTestId("legend-toggle").click(); + await expect(app.getByText(/workflow order, not grid positions/)).toBeVisible(); + await app.getByTestId("legend-toggle").click(); + await expect(app.getByText(/workflow order, not grid positions/)).toBeHidden(); + }); +}); diff --git a/tests/e2e/specs/persistence.spec.ts b/tests/e2e/specs/persistence.spec.ts new file mode 100644 index 0000000..017ff1b --- /dev/null +++ b/tests/e2e/specs/persistence.spec.ts @@ -0,0 +1,37 @@ +import { test, expect, unexpectedErrors } from "../fixtures/base"; +import { acceptNextPrompt } from "../fixtures/dialogs"; +import { LEGACY_V3_STATE } from "../data/seeds"; + +test.describe("@core persistence", () => { + test.use({ seed: "empty" }); + + test("an added member survives reload", async ({ app }) => { + await app.getByRole("button", { name: "Team", exact: true }).click(); + acceptNextPrompt(app, "Dana"); + await app.getByRole("button", { name: "add member" }).click(); + await expect(app.locator('[data-testid^="member-name-"]')).toHaveValue("Dana"); + + await app.reload(); + await app.getByRole("button", { name: "Team", exact: true }).click(); + await expect(app.locator('[data-testid^="member-name-"]')).toHaveValue("Dana"); + }); + + test("theme mode survives reload", async ({ app }) => { + await app.getByRole("button", { name: "Toggle light/dark" }).click(); + await app.reload(); + const mode = await app.evaluate( + () => JSON.parse(localStorage.getItem("joy-matrix-theme-v1") || "{}").mode + ); + expect(mode).toBe("dark"); + }); +}); + +test.describe("@core persistence migration", () => { + test.use({ seed: LEGACY_V3_STATE }); + + test("loads and migrates a legacy schema without crashing", async ({ app, consoleErrors }) => { + await app.getByRole("button", { name: "Team", exact: true }).click(); + await expect(app.getByTestId("member-name-m-1")).toHaveValue("Legacy member"); + expect(unexpectedErrors(consoleErrors)).toEqual([]); + }); +}); diff --git a/tests/e2e/specs/responsive.spec.ts b/tests/e2e/specs/responsive.spec.ts new file mode 100644 index 0000000..b3488ba --- /dev/null +++ b/tests/e2e/specs/responsive.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "../fixtures/base"; + +test.describe("@core @mobile responsive (phone)", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test("collapses inactive tab labels to icons", async ({ app }) => { + // On the Matrix tab, the inactive "Team" tab shows only its icon. + await expect(app.getByText("Team", { exact: true })).toBeHidden(); + // Activating a tab reveals its label. + await app.getByRole("button", { name: "Team", exact: true }).click(); + await expect(app.getByText("Team", { exact: true })).toBeVisible(); + }); + + test("stacks the goal box (both fields usable)", async ({ app }) => { + await app.getByPlaceholder("where you are now").fill("A"); + await app.getByPlaceholder("where you want to be").fill("B"); + await expect(app.getByPlaceholder("where you are now")).toHaveValue("A"); + await expect(app.getByPlaceholder("where you want to be")).toHaveValue("B"); + }); +}); + +test.describe("@core responsive (desktop)", () => { + test.use({ viewport: { width: 1366, height: 768 } }); + + test("shows every tab label", async ({ app }) => { + for (const label of ["Matrix", "Team", "Tasks", "Schedule", "Insights"]) { + await expect(app.getByText(label, { exact: true })).toBeVisible(); + } + }); +}); diff --git a/tests/e2e/specs/schedule.spec.ts b/tests/e2e/specs/schedule.spec.ts new file mode 100644 index 0000000..746771c --- /dev/null +++ b/tests/e2e/specs/schedule.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "../fixtures/base"; +import { SCHEDULE_STATE } from "../data/seeds"; + +test.describe("@core schedule", () => { + test.use({ seed: SCHEDULE_STATE }); + + test.beforeEach(async ({ app }) => { + await app.getByRole("button", { name: "Schedule", exact: true }).click(); + await expect(app.getByRole("heading", { name: "The Schedule" })).toBeVisible(); + }); + + test("week view shows the assignee lane and a placed block", async ({ app }) => { + await expect(app.getByText("Avery").first()).toBeVisible(); + await expect(app.getByText("Build the feature").first()).toBeVisible(); + }); + + test("switches between day / week / month / year views", async ({ app }) => { + await app.getByTestId("schedule-view-day").click(); + await expect(app.getByTestId("schedule-day-label")).toBeVisible(); + + await app.getByTestId("schedule-view-month").click(); + await expect(app.getByTestId("schedule-month-label")).toContainText("2025"); + + await app.getByTestId("schedule-view-year").click(); + await expect(app.getByText(/scheduled hours per day/)).toBeVisible(); + + await app.getByTestId("schedule-view-week").click(); + await expect(app.getByText("Avery").first()).toBeVisible(); + }); + + test("day view navigation moves the date", async ({ app }) => { + await app.getByTestId("schedule-view-day").click(); + const first = await app.getByTestId("schedule-day-label").textContent(); + await app.getByTestId("schedule-next").click(); + await expect(app.getByTestId("schedule-day-label")).not.toHaveText(first || ""); + }); +}); + +test.describe("@core schedule empty", () => { + test.use({ seed: "empty" }); + + test("shows the empty state when there is nothing to schedule", async ({ app }) => { + await app.getByRole("button", { name: "Schedule", exact: true }).click(); + await expect(app.getByText(/Nothing scheduled/)).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/smoke.spec.ts b/tests/e2e/specs/smoke.spec.ts new file mode 100644 index 0000000..5ed363d --- /dev/null +++ b/tests/e2e/specs/smoke.spec.ts @@ -0,0 +1,23 @@ +import { test, expect, unexpectedErrors } from "../fixtures/base"; + +test.describe("@core smoke", () => { + test("boots on demo data with a clean console", async ({ app, consoleErrors }) => { + await expect(app.getByRole("heading", { level: 1 })).toContainText("From"); + // Demo data assigns the landing-page task to Maya in the matrix. + await expect(app.getByText(/Maya/).first()).toBeVisible(); + expect(unexpectedErrors(consoleErrors)).toEqual([]); + }); + + test("renders all five tab sections", async ({ app }) => { + for (const [tab, heading] of [ + ["Matrix", "The Matrix"], + ["Team", "The Team"], + ["Tasks", "The Tasks"], + ["Schedule", "The Schedule"], + ["Insights", "The Reading"], + ] as const) { + await app.getByRole("button", { name: tab, exact: true }).click(); + await expect(app.getByRole("heading", { name: heading })).toBeVisible(); + } + }); +}); diff --git a/tests/e2e/specs/tab-navigation.spec.ts b/tests/e2e/specs/tab-navigation.spec.ts new file mode 100644 index 0000000..f2c08c0 --- /dev/null +++ b/tests/e2e/specs/tab-navigation.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from "../fixtures/base"; + +test.describe("@core navigation", () => { + test("switches sections and resets to Matrix on reload", async ({ app }) => { + await app.getByRole("button", { name: "Schedule", exact: true }).click(); + await expect(app.getByRole("heading", { name: "The Schedule" })).toBeVisible(); + + // Tab selection lives in memory only — a reload returns to Matrix. + await app.reload(); + await expect(app.getByRole("heading", { name: "The Matrix" })).toBeVisible(); + await expect(app.getByRole("heading", { name: "The Schedule" })).toBeHidden(); + }); +}); diff --git a/tests/e2e/specs/tasks-duedate.spec.ts b/tests/e2e/specs/tasks-duedate.spec.ts new file mode 100644 index 0000000..b9b1088 --- /dev/null +++ b/tests/e2e/specs/tasks-duedate.spec.ts @@ -0,0 +1,34 @@ +import { test, expect, type Page } from "../fixtures/base"; +import { SCHEDULE_STATE } from "../data/seeds"; + +const dueDate = (app: Page) => + app.evaluate( + () => JSON.parse(localStorage.getItem("joy-matrix-state-v1") || "{}").tasks[0].dueDate + ); + +test.describe("@core tasks due date", () => { + test.use({ seed: SCHEDULE_STATE }); + + test.beforeEach(async ({ app }) => { + await app.getByRole("button", { name: "Tasks", exact: true }).click(); + await app.getByTestId("task-edit-t-build").click(); + }); + + test("switches between none / fuzzy / exact", async ({ app }) => { + // Seed starts fuzzy "this-week". + await expect(app.getByTestId("task-duedate-fuzzy")).toBeVisible(); + await expect(app.getByTestId("task-duedate-fuzzy").locator("option")).toHaveCount(11); + + await app.getByTestId("task-duedate-mode-none").click(); + await expect(app.getByTestId("task-duedate-fuzzy")).toBeHidden(); + expect(await dueDate(app)).toBeNull(); + + await app.getByTestId("task-duedate-mode-fuzzy").click(); + await app.getByTestId("task-duedate-fuzzy").selectOption("tomorrow"); + expect(await dueDate(app)).toEqual({ kind: "fuzzy", value: "tomorrow" }); + + await app.getByTestId("task-duedate-mode-exact").click(); + await expect(app.getByTestId("task-duedate-exact")).toBeVisible(); + expect((await dueDate(app)).kind).toBe("exact"); + }); +}); diff --git a/tests/e2e/specs/tasks.spec.ts b/tests/e2e/specs/tasks.spec.ts new file mode 100644 index 0000000..eb2d41d --- /dev/null +++ b/tests/e2e/specs/tasks.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from "../fixtures/base"; +import { acceptNextPrompt } from "../fixtures/dialogs"; +import { setRange } from "../helpers/sliders"; +import { SCHEDULE_STATE } from "../data/seeds"; + +test.describe("@core tasks add", () => { + test.use({ seed: "empty" }); + + test("adds a task via the prompt", async ({ app }) => { + await app.getByRole("button", { name: "Tasks", exact: true }).click(); + await expect(app.getByText("No tasks yet.")).toBeVisible(); + + acceptNextPrompt(app, "Draft the spec"); + await app.getByRole("button", { name: "add task" }).click(); + await expect(app.locator('[data-testid^="task-title-"]')).toHaveValue("Draft the spec"); + }); +}); + +test.describe("@core tasks editor", () => { + test.use({ seed: SCHEDULE_STATE }); + + test.beforeEach(async ({ app }) => { + await app.getByRole("button", { name: "Tasks", exact: true }).click(); + await app.getByTestId("task-edit-t-build").click(); + await expect(app.getByTestId("task-urgency")).toBeVisible(); + }); + + test("scoring sliders move the task between quadrants", async ({ app }) => { + const card = app.getByTestId("task-card-t-build"); + await expect(card).toContainText("DO"); // urgency 4, importance 4 + await setRange(app.getByTestId("task-importance"), 1); + await expect(card).toContainText("DELEGATE"); // urgent, not important + }); + + test("edits per-member fit and title", async ({ app }) => { + await setRange(app.getByTestId("task-fit-m-avery-pleasure"), -3); + await expect(app.getByTestId("task-fit-m-avery-pleasure")).toHaveValue("-3"); + + await app.getByTestId("task-title-t-build").fill("Build it well"); + await expect(app.getByTestId("task-title-t-build")).toHaveValue("Build it well"); + }); + + test("assigns category and stakeholder", async ({ app }) => { + await app.getByTestId("task-category").selectOption({ label: "Engineering" }); + await expect(app.getByTestId("task-category")).toHaveValue("c-eng"); + await app.getByTestId("task-stakeholder").selectOption({ label: "Early users" }); + await expect(app.getByTestId("task-stakeholder")).toHaveValue("s-users"); + }); + + test("deletes the task", async ({ app }) => { + // On phone the delete button only shows while the editor is closed, so + // close it first (the beforeEach opened it). On desktop it's always shown. + await app.getByTestId("task-edit-t-build").click(); + await app.getByTestId("task-delete-t-build").click(); + await expect(app.getByTestId("task-card-t-build")).toBeHidden(); + await expect(app.getByText("No tasks yet.")).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/team.spec.ts b/tests/e2e/specs/team.spec.ts new file mode 100644 index 0000000..55101cd --- /dev/null +++ b/tests/e2e/specs/team.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "../fixtures/base"; +import { acceptNextPrompt } from "../fixtures/dialogs"; +import { setRange } from "../helpers/sliders"; +import { SCHEDULE_STATE } from "../data/seeds"; + +test.describe("@core team add/remove", () => { + test.use({ seed: "empty" }); + + test("adds then removes a member", async ({ app }) => { + await app.getByRole("button", { name: "Team", exact: true }).click(); + await expect(app.getByText(/No team members yet/)).toBeVisible(); + + acceptNextPrompt(app, "Dana"); + await app.getByRole("button", { name: "add member" }).click(); + const nameInput = app.locator('[data-testid^="member-name-"]'); + await expect(nameInput).toHaveValue("Dana"); + + await app.locator('[data-testid^="member-remove-"]').first().click(); + await expect(nameInput).toHaveCount(0); + await expect(app.getByText(/No team members yet/)).toBeVisible(); + }); + + test("ignores add when the prompt is cancelled", async ({ app }) => { + await app.getByRole("button", { name: "Team", exact: true }).click(); + // No prompt handler → dialog auto-dismisses → no member added. + await app.getByRole("button", { name: "add member" }).click(); + await expect(app.getByText(/No team members yet/)).toBeVisible(); + }); +}); + +test.describe("@core team controls", () => { + test.use({ seed: SCHEDULE_STATE }); + + test.beforeEach(async ({ app }) => { + await app.getByRole("button", { name: "Team", exact: true }).click(); + await expect(app.getByTestId("member-card-m-avery")).toBeVisible(); + }); + + test("edits name and capacity", async ({ app }) => { + await app.getByTestId("member-name-m-avery").fill("Avery R."); + await expect(app.getByTestId("member-name-m-avery")).toHaveValue("Avery R."); + + await setRange(app.getByTestId("member-capacity-m-avery"), -2); + await expect(app.getByTestId("member-card-m-avery")).toContainText("near limit"); + }); + + test("tunes category baselines", async ({ app }) => { + await app.getByTestId("toggle-baselines-m-avery").click(); + const slider = app.getByTestId("baseline-m-avery-c-eng-pleasure"); + await expect(slider).toBeVisible(); + await setRange(slider, 3); + await expect(slider).toHaveValue("3"); + }); + + test("removes and refills an availability range", async ({ app }) => { + await app.getByTestId("toggle-availability-m-avery").click(); + await expect(app.getByTestId("avail-m-avery-mon-from-0")).toHaveValue("09:00"); + + // Remove Monday's only window — the row falls back to "off". + await app.getByTestId("avail-m-avery-mon-remove-0").click(); + await expect(app.getByTestId("avail-m-avery-mon-from-0")).toBeHidden(); + + // The quick-fill button restores weekday windows. + await app.getByTestId("avail-m-avery-fill-weekdays").click(); + await expect(app.getByTestId("avail-m-avery-mon-from-0")).toHaveValue("09:00"); + }); + + test("tunes energy/concentration windows", async ({ app }) => { + await app.getByTestId("toggle-windows-m-avery").click(); + const energy = app.getByTestId("window-m-avery-morning-energy"); + await expect(energy).toBeVisible(); + await setRange(energy, 3); + await expect(energy).toHaveValue("3"); + }); +}); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..345fc2e --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["**/*.ts"] +}