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)