Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions e2e/tasks/create-task.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test } from "@playwright/test";
import {
createTask,
expectTaskVisible,
prepareTaskPage,
} from "../utils/task-test-utils";

test.describe("Task Creation", () => {
test.skip(
({ isMobile }) => isMobile,
"Tasks are not available in the current mobile experience.",
);

test("should create a new task using the UI", async ({ page }) => {
await prepareTaskPage(page);

const taskTitle = "New Test Task";
await createTask(page, taskTitle);

await expectTaskVisible(page, taskTitle);
});
});
30 changes: 30 additions & 0 deletions e2e/tasks/task-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test } from "@playwright/test";
import {
createTask,
expectTaskSavedToIndexedDB,
expectTaskVisible,
prepareTaskPage,
} from "../utils/task-test-utils";

test.describe("Task Persistence", () => {
test.skip(
({ isMobile }) => isMobile,
"Tasks are not available in the current mobile experience.",
);

test("should persist tasks after reload (IndexedDB)", async ({ page }) => {
await prepareTaskPage(page);

const taskTitle = `Persistent Task ${Date.now()}`;
await createTask(page, taskTitle);

// Verify it's visible
await expectTaskVisible(page, taskTitle);
await expectTaskSavedToIndexedDB(page, taskTitle);

// Reload and verify persistence.
await page.reload();
await expectTaskSavedToIndexedDB(page, taskTitle);
await expectTaskVisible(page, taskTitle, 10000);
});
});
39 changes: 35 additions & 4 deletions e2e/utils/event-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,42 @@ export const prepareCalendarPage = async (page: Page) => {

export const resetLocalEventDb = async (page: Page) => {
await page.evaluate(async (dbName) => {
const clearStore = async (
db: IDBDatabase,
storeName: string,
): Promise<void> => {
if (!db.objectStoreNames.contains(storeName)) {
return;
}

await new Promise<void>((resolve) => {
const transaction = db.transaction(storeName, "readwrite");
const clearRequest = transaction.objectStore(storeName).clear();
clearRequest.onerror = () => resolve();
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
transaction.onabort = () => resolve();
});
};

await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
const deleteRequest = indexedDB.deleteDatabase(dbName);

deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => resolve();
deleteRequest.onblocked = async () => {
// If the app still has an open connection, fall back to clearing stores
// so tests still start from a clean state.
const openRequest = indexedDB.open(dbName);
openRequest.onerror = () => resolve();
openRequest.onsuccess = async () => {
const db = openRequest.result;
await clearStore(db, "events");
await clearStore(db, "tasks");
db.close();
resolve();
};
};
});
}, LOCAL_DB_NAME);
};
Expand Down
159 changes: 159 additions & 0 deletions e2e/utils/task-test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { type Page, expect } from "@playwright/test";
import { resetLocalEventDb } from "./event-test-utils";
import { ONBOARDING_STATE } from "./test-constants";

export const prepareTaskPage = async (page: Page) => {
await page.addInitScript((value) => {
localStorage.setItem("compass.onboarding", JSON.stringify(value));
}, ONBOARDING_STATE);

await page.goto("/day", { waitUntil: "networkidle" });
await page.waitForURL(/\/day\/\d{4}-\d{2}-\d{2}$/);
await page.waitForFunction(
() => {
const root = document.querySelector("#root");
return root && root.children.length > 0;
},
{ timeout: 10000 },
);

await resetLocalEventDb(page);
await page.reload({ waitUntil: "networkidle" });
await page.waitForURL(/\/day\/\d{4}-\d{2}-\d{2}$/);
await page.waitForFunction(
() => {
const root = document.querySelector("#root");
return root && root.children.length > 0;
},
{ timeout: 10000 },
);

// Wait for task list to be visible
await expect(page.locator('[aria-label="daily-tasks"]')).toBeVisible();
};

export const createTask = async (page: Page, title: string) => {
// Check if input is already visible
const input = page.getByPlaceholder("Enter task title...");

if (!(await input.isVisible())) {
const addTaskButton = page.getByRole("button", { name: "Create new task" });
if (await addTaskButton.isVisible()) {
await addTaskButton.click();
} else {
// Try shortcut 'c'
await page.keyboard.press("c");
}
}

await expect(input).toBeVisible();
await input.fill(title);
await input.press("Enter");
};

export const expectTaskVisible = async (
page: Page,
title: string,
timeout = 10000,
) => {
// Tasks are rendered as inputs
await expect(page.locator(`input[value="${title}"]`)).toBeVisible({
timeout,
});
};

export const expectTaskSavedToIndexedDB = async (page: Page, title: string) => {
let lastSnapshot: unknown = null;

for (let attempt = 0; attempt < 20; attempt++) {
lastSnapshot = await page.evaluate(async () => {
const currentDateKey = window.location.pathname.split("/").pop() ?? "";

return await new Promise<{
openError?: boolean;
storeError?: string;
currentDateKey?: string;
tasks?: Array<{ title: string; dateKey: string }>;
}>((resolve) => {
const openRequest = indexedDB.open("compass-local");
openRequest.onerror = () => resolve({ openError: true });
openRequest.onsuccess = () => {
const db = openRequest.result;

let store;
try {
const transaction = db.transaction("tasks", "readonly");
store = transaction.objectStore("tasks");
} catch (error) {
resolve({ storeError: String(error) });
return;
}

const getAllRequest = store.getAll();
getAllRequest.onerror = () => resolve({ currentDateKey, tasks: [] });
getAllRequest.onsuccess = () => {
const tasks = getAllRequest.result as Array<{
title?: string;
dateKey?: string;
}>;
resolve({
currentDateKey,
tasks: tasks
.filter(
(task): task is { title: string; dateKey: string } =>
Boolean(task?.title) && Boolean(task?.dateKey),
)
.map((task) => ({
title: task.title,
dateKey: task.dateKey,
})),
});
};
};
});
});

if (
typeof lastSnapshot === "object" &&
lastSnapshot !== null &&
"tasks" in lastSnapshot
) {
const tasks =
(
lastSnapshot as {
tasks?: Array<{ title: string; dateKey: string }>;
}
).tasks ?? [];
const currentDateKey =
(lastSnapshot as { currentDateKey?: string }).currentDateKey ?? "";

const isTaskSavedForCurrentDate = tasks.some(
(task) => task.title === title && task.dateKey === currentDateKey,
);

if (isTaskSavedForCurrentDate) {
return;
}
}

await page.waitForTimeout(200);
}

throw new Error(
`Task was not found in IndexedDB after polling: ${JSON.stringify(lastSnapshot)}`,
);
};

export const clearAllLocalData = async (page: Page) => {
await resetLocalEventDb(page);
await page.reload();
await expect(page.locator('[aria-label="daily-tasks"]')).toBeVisible();
};

/**
* @deprecated Use clearAllLocalData. This clears the shared local IndexedDB
* database (both events and tasks).
*/
export const clearTasks = async (page: Page) => {
await clearAllLocalData(page);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Origin, Priorities } from "@core/constants/core.constants";
import { Event_Core } from "@core/types/event.types";
import dayjs from "@core/util/date/dayjs";
import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory";
import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants";
import { Task } from "@web/common/types/task.types";

/**
Expand Down Expand Up @@ -49,6 +50,7 @@ export const createTestTask = (overrides: Partial<Task> = {}): Task => {
status: "todo",
order: 0,
createdAt: new Date().toISOString(),
user: UNAUTHENTICATED_USER,
...overrides,
};
};
Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/__tests__/utils/storage/indexeddb.test.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const COMPASS_LOCAL_DB_NAME = "compass-local";

export async function clearCompassLocalDb(): Promise<void> {
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(COMPASS_LOCAL_DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
}
55 changes: 40 additions & 15 deletions packages/web/src/__tests__/utils/tasks/task.test.util.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,73 @@
import { act } from "react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

type User = ReturnType<typeof userEvent.setup>;

export const waitForTaskListReady = async () => {
await waitFor(() => {
expect(
screen.getByRole("region", { name: "daily-tasks" }),
).toBeInTheDocument();
});

await waitFor(() => {
expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument();
});
};

export const addTasks = async (user: User, taskTitles: string[]) => {
for (const title of taskTitles) {
const initialTitleCount = screen.queryAllByRole("checkbox", {
name: `Toggle ${title}`,
}).length;
const expectedTitleCount = initialTitleCount + 1;

// Wait for the add button to be available
await clickCreateTaskButton(user);

// Wait for the input to appear
const input = await waitFor(() =>
const input = (await waitFor(() =>
screen.getByPlaceholderText("Enter task title..."),
);
)) as HTMLInputElement;

await act(async () => {
await user.type(input, `${title}{Enter}`);
});
await user.clear(input);
await user.type(input, title);
await user.keyboard("{Enter}");

// Wait for the task to be created and appear in the DOM
await waitFor(
() => {
const elements = screen.getAllByDisplayValue(title);
expect(elements.length).toBeGreaterThan(0);
const elements = screen.getAllByRole("checkbox", {
name: `Toggle ${title}`,
});
expect(elements.length).toBeGreaterThanOrEqual(expectedTitleCount);
},
{ timeout: 5000 },
);
}
};

export const clickCreateTaskButton = async (user: User) => {
const addButton = await waitFor(() =>
screen.getByRole("button", { name: "Create new task" }),
await waitForTaskListReady();

const existingInput = screen.queryByRole("textbox", { name: /task title/i });
if (existingInput) return existingInput;

const addButton = await waitFor(
() => screen.getByRole("button", { name: "Create new task" }),
{ timeout: 5000 },
);
await act(async () => {
await user.click(addButton);

await user.click(addButton);

return waitFor(() => screen.getByRole("textbox", { name: /task title/i }), {
timeout: 5000,
});
};

export const focusOnTaskCheckbox = async (user: User, title: string) => {
const checkbox = await waitFor(() =>
screen.getByRole("checkbox", { name: `Toggle ${title}` }),
);
await act(async () => {
checkbox.focus();
});
checkbox.focus();
};
Loading