diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bcb1202..66beb17c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,17 @@ jobs: # todo(nickbar01234): Cache playwright? - name: Install Playwright browsers run: pnpm exec playwright install --with-deps + + - name: Start Firebase emulator + run: docker compose up -d + - name: Wait for firebase emulator + run: | + echo "Waiting to check firebase to be healthy" + until [ "$(docker inspect --format='{{.State.Health.Status}}' firebase)" = "healthy" ]; do + echo "Waiting..." + sleep 2 + done + echo "Firebase is healthy" # todo(nickbar01234): Should switch to chrome-mv3 for production build or setup CI to point to docker firebase - name: Build extension in dev mode run: pnpm build:dev diff --git a/extension/package.json b/extension/package.json index 21cb9ff8..4ecb2677 100644 --- a/extension/package.json +++ b/extension/package.json @@ -19,7 +19,7 @@ "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/extension/src/components/panel/editor/EditorPanel.tsx b/extension/src/components/panel/editor/EditorPanel.tsx index 9534d206..ab9308cf 100644 --- a/extension/src/components/panel/editor/EditorPanel.tsx +++ b/extension/src/components/panel/editor/EditorPanel.tsx @@ -113,6 +113,7 @@ const EditorPanel = () => { trigger={{ node: (
diff --git a/extension/src/components/panel/editor/tab/CodeTab.tsx b/extension/src/components/panel/editor/tab/CodeTab.tsx index 9ac263cd..ee88d822 100644 --- a/extension/src/components/panel/editor/tab/CodeTab.tsx +++ b/extension/src/components/panel/editor/tab/CodeTab.tsx @@ -17,7 +17,7 @@ export const CodeTab: React.FC = () => { className="h-fit hover:bg-fill-quaternary dark:hover:bg-fill-quaternary inline-flex items-center justify-between focus:outline-none p-2 rounded-md cursor-pointer" onClick={copyCode} > - +
), }} diff --git a/extension/src/components/panel/problem/index.tsx b/extension/src/components/panel/problem/index.tsx index a41b42a9..65c65483 100644 --- a/extension/src/components/panel/problem/index.tsx +++ b/extension/src/components/panel/problem/index.tsx @@ -3,6 +3,7 @@ import { DOM, EXTENSION } from "@cb/constants"; import { useHtmlActions } from "@cb/hooks/store"; import useResource from "@cb/hooks/useResource"; import { Question } from "@cb/types"; +import { waitForElement } from "@cb/utils"; import React, { useEffect } from "react"; import { toast } from "sonner"; diff --git a/extension/src/components/root/ContentScript.tsx b/extension/src/components/root/ContentScript.tsx index 6ca71940..f553dece 100644 --- a/extension/src/components/root/ContentScript.tsx +++ b/extension/src/components/root/ContentScript.tsx @@ -1,7 +1,6 @@ import { AppNavigator } from "@cb/components/navigator/AppNavigator"; import { ContainerNavigator } from "@cb/components/navigator/ContainerNavigator"; import { AppControlMenu, RoomControlMenu } from "@cb/components/navigator/menu"; -import { BottomBannerPanel } from "@cb/components/panel/BottomBannerPanel"; import { LoadingPanel } from "@cb/components/panel/LoadingPanel"; import { ResizablePanel } from "@cb/components/panel/ResizablePanel"; import SignInPanel from "@cb/components/panel/SignInPanel"; @@ -22,9 +21,7 @@ export const ContentScript = () => { case AppStatus.AUTHENTICATED: return ( }> - - - + ); case AppStatus.UNAUTHENTICATED: diff --git a/extension/tests/fixture.ts b/extension/tests/fixture.ts deleted file mode 100644 index 1cff490d..00000000 --- a/extension/tests/fixture.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { DOM } from "@cb/constants"; -import { test as base, chromium, type BrowserContext } from "@playwright/test"; -import fs from "node:fs"; -import { dirname, resolve } from "path"; -import { fileURLToPath } from "url"; - -const extension = resolve( - dirname(fileURLToPath(import.meta.url)), - "../dist/chrome-mv3-dev" -); - -if (!fs.existsSync(extension)) { - throw new Error(`Invalid path ${extension}`); -} - -export const test = base.extend<{ - context: BrowserContext; - extensionId: string; -}>({ - // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { - const context = await chromium.launchPersistentContext("", { - headless: false, - channel: "chromium", - args: [ - `--disable-extensions-except=${extension}`, - `--load-extension=${extension}`, - ], - }); - await use(context); - await context.close(); - }, - extensionId: async ({ context }, use) => { - let [serviceWorker] = context.serviceWorkers(); - if (!serviceWorker) - serviceWorker = await context.waitForEvent("serviceworker"); - - const extensionId = serviceWorker.url().split("/")[2]; - await use(extensionId); - }, - page: async ({ page }, use) => { - page.on("console", (msg) => { - console.log("Received message from page", msg.text(), msg.type()); - }); - await page.goto("https://leetcode.com/problems/two-sum", { - waitUntil: "domcontentloaded", - }); - await page.waitForSelector(DOM.LEETCODE_ROOT_ID, { - state: "visible", - timeout: 30_000, - }); - await use(page); - }, -}); - -export const expect = test.expect; diff --git a/extension/tests/fixture/factory.ts b/extension/tests/fixture/factory.ts new file mode 100644 index 00000000..4d1d92dc --- /dev/null +++ b/extension/tests/fixture/factory.ts @@ -0,0 +1,49 @@ +import { test as base, BrowserContext, chromium, Page } from "@playwright/test"; +import { signIn } from "@tests/utils/auth"; +import { getExtensionId, getExtensionPath, setupPage } from "@tests/utils/page"; + +export interface UserPage { + page: Page; + email: string; + extensionId: string; + context: BrowserContext; +} + +interface PlayWrightPageFactory { + instantiate: (email: string) => Promise; +} +const extension = getExtensionPath(); + +export const factory = base.extend<{ pageCreator: PlayWrightPageFactory }>({ + // eslint-disable-next-line no-empty-pattern + pageCreator: async ({}, use) => { + const contexts: BrowserContext[] = []; + const instantiate: PlayWrightPageFactory["instantiate"] = async (email) => { + const context = await chromium.launchPersistentContext("", { + headless: false, + channel: "chromium", + args: [ + `--disable-extensions-except=${extension}`, + `--load-extension=${extension}`, + ], + permissions: [ + "clipboard-read", + "clipboard-write", + "local-network-access", + ], + }); + contexts.push(context); + + const extensionId = await getExtensionId(context); + const page = context.pages()[0] ?? (await context.newPage()); + await setupPage(page); + await signIn(page, email); + + return { page, context, email, extensionId }; + }; + + await use({ instantiate }); + + await Promise.all(contexts.map((ctx) => ctx.close())); + }, +}); diff --git a/extension/tests/fixture/in-room.ts b/extension/tests/fixture/in-room.ts new file mode 100644 index 00000000..31567068 --- /dev/null +++ b/extension/tests/fixture/in-room.ts @@ -0,0 +1,36 @@ +import { createRoom, joinRoom, RoomInfo } from "@tests/utils/room"; +import { factory, UserPage } from "./factory"; + +interface UserInRoomPage extends UserPage { + room: RoomInfo; +} + +export const singleUserTest = factory.extend<{ + user: UserInRoomPage; +}>({ + user: async ({ pageCreator }, use) => { + const user = await pageCreator.instantiate("user@test.com"); + const room = await createRoom(user.page); + await use({ ...user, room }); + }, +}); + +export const twoUserTest = factory.extend<{ + user1: UserInRoomPage; + user2: UserInRoomPage; +}>({ + user1: async ({ pageCreator }, use) => { + const user = await pageCreator.instantiate("user1@test.com"); + const room = await createRoom(user.page); + await use({ ...user, room }); + }, + + user2: async ({ pageCreator, user1 }, use) => { + const user = await pageCreator.instantiate("user2@test.com"); + await joinRoom(user.page, user1.room.id); + await use({ ...user, room: user1.room }); + }, +}); + +export const twoUserExpect = twoUserTest.expect; +export const singleUserExpect = singleUserTest.expect; diff --git a/extension/tests/pages/content.spec.ts b/extension/tests/pages/content.spec.ts index 791f9e4b..0f264c86 100644 --- a/extension/tests/pages/content.spec.ts +++ b/extension/tests/pages/content.spec.ts @@ -1,7 +1,21 @@ -import { expect, test } from "@tests/fixture"; +import { twoUserExpect, twoUserTest } from "@tests/fixture/in-room"; -test("Content script is mounted", async ({ page }) => { - await expect(page.getByText("CodeBuddy").first()).toBeVisible({ - timeout: 30_000, - }); +const EXPECTED_CPP_CODE = `class Solution { +public: + vector twoSum(vector& nums, int target) { + + } +};`; + +twoUserTest("User1 can copy code from User2", async ({ user1, user2 }) => { + await user1.page.getByRole("tab", { name: /Code/i }).click(); + await user1.page.getByTestId("toggle-code-visibility").click(); + await user1.page.getByTestId("copy-code").click({ timeout: 30_000 }); + + await twoUserExpect(async () => { + const copiedCode = await user1.page.evaluate(() => + navigator.clipboard.readText() + ); + twoUserExpect(copiedCode.trim()).toBe(EXPECTED_CPP_CODE.trim()); + }).toPass(); }); diff --git a/extension/tests/scripts/append-test-case.spec.ts b/extension/tests/scripts/append-test-case.spec.ts deleted file mode 100644 index db305857..00000000 --- a/extension/tests/scripts/append-test-case.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { WindowMessage } from "@cb/types"; -import { expect, test } from "@tests/fixture"; - -test("Copy test", async ({ page }) => { - const testValues = ["[3,2,4]", "9"]; - - await page.waitForSelector('button[data-e2e-locator="console-testcase-tag"]'); - - const initialCount = await page.$$eval( - 'button[data-e2e-locator="console-testcase-tag"]', - (buttons) => buttons.length - ); - - await expect(async () => { - await page.evaluate((values) => { - const message: WindowMessage = { - action: "appendTestCaseToLeetCode", - testValues: values, - }; - window.postMessage(message, "*"); - }, testValues); - const buttons = await page.$$( - 'button[data-e2e-locator="console-testcase-tag"]' - ); - expect(buttons.length).toBe(initialCount + 1); - }).toPass(); - - await expect(async () => { - const inputs = await page.$$( - 'div[data-e2e-locator="console-testcase-input"][contenteditable="true"]' - ); - expect(inputs.length).toBe(testValues.length); - - for (let i = 0; i < inputs.length; i++) { - const text = await inputs[i].textContent(); - expect(text?.trim()).toBe(testValues[i]); - } - }).toPass(); -}); diff --git a/extension/tests/scripts/router.spec.ts b/extension/tests/scripts/router.spec.ts deleted file mode 100644 index 6985c51c..00000000 --- a/extension/tests/scripts/router.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { WindowMessage } from "@cb/types"; -import { getNormalizedUrl } from "@cb/utils"; -import { expect, test } from "@tests/fixture"; - -test("Navigate to different problem", async ({ page }) => { - const navigateTo = "https://leetcode.com/problems/add-two-numbers"; - expect(getNormalizedUrl(page.url())).toMatch( - "https://leetcode.com/problems/two-sum" - ); - await expect(async () => { - await page.evaluate((url) => { - const navigate: WindowMessage = { - action: "navigate", - url, - }; - window.postMessage(navigate); - }, navigateTo); - expect(getNormalizedUrl(page.url())).toBe(navigateTo); - }).toPass(); -}); diff --git a/extension/tests/utils/auth.ts b/extension/tests/utils/auth.ts new file mode 100644 index 00000000..8bce17fe --- /dev/null +++ b/extension/tests/utils/auth.ts @@ -0,0 +1,6 @@ +import type { Page } from "@playwright/test"; + +export async function signIn(page: Page, email: string): Promise { + await page.getByRole("textbox", { name: "Enter your email" }).fill(email); + await page.getByRole("button", { name: "Continue" }).click(); +} diff --git a/extension/tests/utils/page.ts b/extension/tests/utils/page.ts new file mode 100644 index 00000000..9ec58a73 --- /dev/null +++ b/extension/tests/utils/page.ts @@ -0,0 +1,51 @@ +import { DOM } from "@cb/constants"; +import { chromium, type BrowserContext, type Page } from "@playwright/test"; +import fs from "node:fs"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const extension = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../dist/chrome-mv3-dev" +); + +if (!fs.existsSync(extension)) { + throw new Error(`Invalid path ${extension}`); +} + +export function getExtensionPath(): string { + return extension; +} + +export async function createExtensionContext(): Promise { + const context = await chromium.launchPersistentContext("", { + headless: false, + channel: "chromium", + args: [ + `--disable-extensions-except=${extension}`, + `--load-extension=${extension}`, + ], + permissions: ["clipboard-read", "clipboard-write", "local-network-access"], + }); + return context; +} + +export async function getExtensionId(context: BrowserContext): Promise { + let [serviceWorker] = context.serviceWorkers(); + if (!serviceWorker) + serviceWorker = await context.waitForEvent("serviceworker"); + return serviceWorker.url().split("/")[2]; +} + +export async function setupPage(page: Page): Promise { + page.on("console", (msg) => { + console.log("Received message from page", msg.text(), msg.type()); + }); + await page.goto("https://leetcode.com/problems/two-sum", { + waitUntil: "domcontentloaded", + }); + await page.waitForSelector(DOM.LEETCODE_ROOT_ID, { + state: "visible", + timeout: 30_000, + }); +} diff --git a/extension/tests/utils/room.ts b/extension/tests/utils/room.ts new file mode 100644 index 00000000..46372d43 --- /dev/null +++ b/extension/tests/utils/room.ts @@ -0,0 +1,22 @@ +import type { Page } from "@playwright/test"; + +export interface RoomInfo { + id: string; +} + +export async function createRoom(page: Page): Promise { + await page.getByRole("button", { name: "Create Room" }).click(); + await page.getByRole("radio", { name: "Private" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + await page.getByRole("img", { name: "Copy room ID" }).click(); + + const roomId = await page.evaluate(() => navigator.clipboard.readText()); + + return { id: roomId }; +} + +export async function joinRoom(page: Page, roomId: string): Promise { + await page.getByRole("button", { name: "Join room" }).click(); + await page.getByRole("textbox", { name: "Enter room ID" }).fill(roomId); + await page.getByRole("button", { name: "Join" }).click(); +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 5bddaa48..a400bd5c 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -6,6 +6,6 @@ "@cb/*": ["./src/*"] } }, - "exclude": ["./**/*.spec.ts", ".output"], + "exclude": ["./**/*.spec.ts", ".output", "tests"], "extends": ["./.wxt/tsconfig.json"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ab80f48..23e5a4b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-slot': - specifier: ^1.2.4 - version: 1.2.4(@types/react@18.3.20)(react@19.1.1) + specifier: ^1.1.2 + version: 1.1.2(@types/react@18.3.20)(react@19.1.1) '@radix-ui/react-switch': specifier: ^1.2.6 version: 1.2.6(@types/react-dom@18.3.5(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)