diff --git a/.env.example b/.env.example index c242397298..52d50fb4f8 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ CHROME_EXTENSION_ID=mpjjildhmpddojocokjkgmlkkkfjnepo # Shadow DOM mode for all components. Default is SHADOW_DOM=closed in regular webpack builds, open elsewhere # SHADOW_DOM=open +# Enable telemetry in development. Default is false +# DEV_EVENT_TELEMETRY=true + # This makes all optional permissions required in the manifest.json to avoid permission popups. Only required for Playwright tests. # REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f9f3e4d73..79cc497a5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,6 @@ env: PUBLIC_RELEASE: ${{ github.ref == 'refs/heads/main' }} # Staging URL, also directly used by webpack SERVICE_URL: https://app-stg.pixiebrix.com/ - E2E_TEST_USER_EMAIL_UNAFFILIATED: ${{ secrets.E2E_TEST_USER_EMAIL_UNAFFILIATED }} - E2E_TEST_USER_PASSWORD_UNAFFILIATED: ${{ secrets.E2E_TEST_USER_PASSWORD_UNAFFILIATED }} jobs: test: @@ -205,6 +203,9 @@ jobs: DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }} DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }} REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST: 1 + E2E_TEST_USER_EMAIL_UNAFFILIATED: ${{ secrets.E2E_TEST_USER_EMAIL_UNAFFILIATED }} + E2E_TEST_USER_PASSWORD_UNAFFILIATED: ${{ secrets.E2E_TEST_USER_PASSWORD_UNAFFILIATED }} + DEV_EVENT_TELEMETRY: true steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/end-to-end-tests/README.md b/end-to-end-tests/README.md index 504928c42b..a444566995 100644 --- a/end-to-end-tests/README.md +++ b/end-to-end-tests/README.md @@ -15,11 +15,13 @@ One-time setup: - The test user password `E2E_TEST_USER_PASSWORD_UNAFFILIATED` - Uncomment `REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST=1` - Uncomment `SHADOW_DOM=open` + - Uncomment `DATADOG_CLIENT_TOKEN` (used for telemetry tests, can be set to a fake token, e.g. `secret123`) + - Uncomment `DEV_EVENT_TELEMETRY` (used for telemetry tests, actual telemetry requests should be mocked during testing) - `MV` will determine the manifest version for the both the extension and the tests (defaulted to 3 if not defined.) - Install browsers: Execute `npx playwright install chromium chrome msedge`. 1. Install dependencies: Run `npm install` -2. Build the extension: Run: `npm run build:webpack` (or `npm run watch`) +2. Build the extension: Run: `npm run watch` 3. Run the tests: Use the command `npm run test:e2e`. - To run tests in interactive UI mode, use `npm run test:e2e -- --ui`. This view shows you the entire test suite and diff --git a/end-to-end-tests/auth.setup.ts b/end-to-end-tests/auth.setup.ts index c74ad1dd94..3435b67830 100644 --- a/end-to-end-tests/auth.setup.ts +++ b/end-to-end-tests/auth.setup.ts @@ -16,7 +16,7 @@ */ import { expect, type Page } from "@playwright/test"; -import { test } from "./fixtures/authSetupFixture"; +import { test } from "./fixtures/authSetup"; import { E2E_TEST_USER_EMAIL_UNAFFILIATED, E2E_TEST_USER_PASSWORD_UNAFFILIATED, diff --git a/end-to-end-tests/env.ts b/end-to-end-tests/env.ts index 486f4c7add..a1b0ca3e94 100644 --- a/end-to-end-tests/env.ts +++ b/end-to-end-tests/env.ts @@ -24,6 +24,7 @@ const requiredEnvVariables = [ "SERVICE_URL", "E2E_TEST_USER_EMAIL_UNAFFILIATED", "E2E_TEST_USER_PASSWORD_UNAFFILIATED", + "SHADOW_DOM", ] as const; // It's not strictly required for the test run itself, but the extension manifest.json must have been built with @@ -45,20 +46,24 @@ type OptionalEnvVariables = Record< string | undefined >; -for (const key of requiredEnvVariables) { - // eslint-disable-next-line security/detect-object-injection -- key is a constant - if (process.env[key] === undefined) { - throw new Error(`Required environment variable is not configured: ${key}`); - } +export const assertRequiredEnvVariables = () => { + for (const key of requiredEnvVariables) { + // eslint-disable-next-line security/detect-object-injection -- key is a constant + if (process.env[key] === undefined) { + throw new Error( + `Required environment variable is not configured: ${key}`, + ); + } - // eslint-disable-next-line security/detect-object-injection -- key is a constant - if (typeof process.env[key] !== "string") { - // For the time being we expect all of our requiredEnvVariables to be strings - throw new TypeError( - `Required environment variable is not configured: ${key}`, - ); + // eslint-disable-next-line security/detect-object-injection -- key is a constant + if (typeof process.env[key] !== "string") { + // For the time being we expect all of our requiredEnvVariables to be strings + throw new TypeError( + `Required environment variable is not configured: ${key}`, + ); + } } -} +}; export const { SERVICE_URL, @@ -71,5 +76,4 @@ export const { MV = "3", SLOWMO, PWDEBUG, - REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST, } = process.env as OptionalEnvVariables; diff --git a/end-to-end-tests/fixtures/authSetup.ts b/end-to-end-tests/fixtures/authSetup.ts new file mode 100644 index 0000000000..739ad20577 --- /dev/null +++ b/end-to-end-tests/fixtures/authSetup.ts @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + test as base, + mergeTests, + type BrowserContext, + type Page, +} from "@playwright/test"; +import { + getAuthProfilePathFile, + launchPersistentContextWithExtension, +} from "./utils"; +import fs from "node:fs/promises"; +import path from "node:path"; +import * as os from "node:os"; +import { test as envSetup } from "./envSetup"; + +// Create a local auth directory to store the profile paths +const createAuthProfilePathDirectory = async () => { + const authPath = path.join(__dirname, "../.auth"); + try { + await fs.mkdir(authPath); + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "EEXIST") + ) { + throw error; + } + } +}; + +export const test = mergeTests( + envSetup, + base.extend<{ + contextAndPage: { context: BrowserContext; page: Page }; + chromiumChannel: "chrome" | "msedge"; + additionalRequiredEnvVariables: string[]; + }>({ + chromiumChannel: ["chrome", { option: true }], + additionalRequiredEnvVariables: [ + "REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST", + ], + // Provides the context and the initial page together in the same fixture + async contextAndPage({ chromiumChannel }, use, testInfo) { + // Create a temp directory to store the test profile + const authSetupProfileDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), "authSetup-"), + ); + + // Create a local auth directory to store the profile paths + await createAuthProfilePathDirectory(); + + const context = await launchPersistentContextWithExtension( + chromiumChannel, + authSetupProfileDirectory, + ); + + // The admin console automatically opens a new tab to log in and link the newly installed extension to the user's account. + const page = await context.waitForEvent("page", { timeout: 10_000 }); + + await use({ context, page }); + + // Store the profile path for future use if the auth setup test passes + if (testInfo.status === "passed") { + const authProfilePathFile = getAuthProfilePathFile(chromiumChannel); + await fs.writeFile( + authProfilePathFile, + authSetupProfileDirectory, + "utf8", + ); + } + + await context.close(); + }, + }), +); diff --git a/end-to-end-tests/fixtures/authSetupFixture.ts b/end-to-end-tests/fixtures/authSetupFixture.ts deleted file mode 100644 index ede45ca861..0000000000 --- a/end-to-end-tests/fixtures/authSetupFixture.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (C) 2024 PixieBrix, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { test as base, type BrowserContext, type Page } from "@playwright/test"; -import { REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST } from "../env"; -import { - getAuthProfilePathFile, - launchPersistentContextWithExtension, -} from "./utils"; -import fs from "node:fs/promises"; -import path from "node:path"; -import * as os from "node:os"; - -// Create a local auth directory to store the profile paths -const createAuthProfilePathDirectory = async () => { - const authPath = path.join(__dirname, "../.auth"); - try { - await fs.mkdir(authPath); - } catch (error) { - if ( - !(error instanceof Error && "code" in error && error.code === "EEXIST") - ) { - throw error; - } - } -}; - -export const test = base.extend<{ - contextAndPage: { context: BrowserContext; page: Page }; - chromiumChannel: "chrome" | "msedge"; -}>({ - chromiumChannel: ["chrome", { option: true }], - // Provides the context and the initial page together in the same fixture - async contextAndPage({ chromiumChannel }, use, testInfo) { - if (!REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST) { - throw new Error( - "This test requires optional permissions to be required in the manifest. Please set REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST=1 in your `.env.development` and rerun the extension build.", - ); - } - - // Create a temp directory to store the test profile - const authSetupProfileDirectory = await fs.mkdtemp( - path.join(os.tmpdir(), "authSetup-"), - ); - - // Create a local auth directory to store the profile paths - await createAuthProfilePathDirectory(); - - const context = await launchPersistentContextWithExtension( - chromiumChannel, - authSetupProfileDirectory, - ); - - // The admin console automatically opens a new tab to log in and link the newly installed extension to the user's account. - const page = await context.waitForEvent("page", { timeout: 10_000 }); - - await use({ context, page }); - - // Store the profile path for future use if the auth setup test passes - if (testInfo.status === "passed") { - const authProfilePathFile = getAuthProfilePathFile(chromiumChannel); - await fs.writeFile( - authProfilePathFile, - authSetupProfileDirectory, - "utf8", - ); - } - - await context.close(); - }, -}); diff --git a/end-to-end-tests/fixtures/envSetup.ts b/end-to-end-tests/fixtures/envSetup.ts new file mode 100644 index 0000000000..2ef13b00e9 --- /dev/null +++ b/end-to-end-tests/fixtures/envSetup.ts @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { test as base } from "@playwright/test"; +import { assertRequiredEnvVariables } from "../env"; + +export const test = base.extend<{ + additionalRequiredEnvVariables: string[]; + expectRequiredEnvVariables: void; +}>({ + additionalRequiredEnvVariables: [], + expectRequiredEnvVariables: [ + async ({ additionalRequiredEnvVariables }, use) => { + assertRequiredEnvVariables(); + + for (const key of additionalRequiredEnvVariables) { + if (process.env[key] === undefined) { + throw new Error( + `This test requires additional environment variable ${key} to be configured. Configure it in your .env.development file and re-build the extension.`, + ); + } + } + + await use(); + }, + { auto: true }, + ], +}); diff --git a/end-to-end-tests/fixtures/extensionBase.ts b/end-to-end-tests/fixtures/extensionBase.ts index f2ca1b3925..d06923a5f9 100644 --- a/end-to-end-tests/fixtures/extensionBase.ts +++ b/end-to-end-tests/fixtures/extensionBase.ts @@ -15,7 +15,11 @@ * along with this program. If not, see . */ -import { test as base, type BrowserContext } from "@playwright/test"; +import { + test as base, + mergeTests, + type BrowserContext, +} from "@playwright/test"; import path from "node:path"; import fs from "node:fs/promises"; import { @@ -24,6 +28,7 @@ import { launchPersistentContextWithExtension, } from "./utils"; import { ModsPage } from "../pageObjects/extensionConsole/modsPage"; +import { test as envSetup } from "./envSetup"; // This environment variable is used to attach the browser sidepanel window that opens automatically to Playwright. // See https://github.com/microsoft/playwright/issues/26693 @@ -33,67 +38,75 @@ process.env.PW_CHROMIUM_ATTACH_TO_OTHER = "1"; // See https://playwright.dev/docs/service-workers-experimental process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = "1"; -export const test = base.extend<{ - context: BrowserContext; - extensionId: string; - chromiumChannel: "chrome" | "msedge"; -}>({ - chromiumChannel: ["chrome", { option: true }], - async context({ chromiumChannel }, use) { - let authSetupProfileDirectory: string; +export const test = mergeTests( + envSetup, + base.extend< + { + context: BrowserContext; + extensionId: string; + chromiumChannel: "chrome" | "msedge"; + }, + { + checkRequiredEnvironmentVariables: () => void; + } + >({ + chromiumChannel: ["chrome", { option: true }], + async context({ chromiumChannel }, use) { + let authSetupProfileDirectory: string; - try { - authSetupProfileDirectory = await fs.readFile( - getAuthProfilePathFile(chromiumChannel), - "utf8", - ); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) { - console.log( - "No auth setup profile found. Make sure that the `auth.setup` project has been run first to create the " + - "profile. (If using UI mode, make sure that the chromeSetup and/or the edgeSetup projects are not filtered out)", + try { + authSetupProfileDirectory = await fs.readFile( + getAuthProfilePathFile(chromiumChannel), + "utf8", ); - } + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + console.log( + "No auth setup profile found. Make sure that the `auth.setup` project has been run first to create the " + + "profile. (If using UI mode, make sure that the chromeSetup and/or the edgeSetup projects are not filtered out)", + ); + } - throw error; - } + throw error; + } - const temporaryProfileDirectory = await fs.mkdtemp( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion,@typescript-eslint/no-non-null-assertion -- checked above - path.join(path.dirname(authSetupProfileDirectory!), "e2e-test-"), - ); - // Copy the auth setup profile to a new temp directory to avoid modifying the original auth profile - await fs.cp(authSetupProfileDirectory, temporaryProfileDirectory, { - recursive: true, - }); + const temporaryProfileDirectory = await fs.mkdtemp( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion,@typescript-eslint/no-non-null-assertion -- checked above + path.join(path.dirname(authSetupProfileDirectory!), "e2e-test-"), + ); + // Copy the auth setup profile to a new temp directory to avoid modifying the original auth profile + await fs.cp(authSetupProfileDirectory, temporaryProfileDirectory, { + recursive: true, + }); - const context = await launchPersistentContextWithExtension( - chromiumChannel, - temporaryProfileDirectory, - ); + const context = await launchPersistentContextWithExtension( + chromiumChannel, + temporaryProfileDirectory, + ); - await use(context); - await context.close(); - }, - async page({ context, extensionId }, use) { - // Re-use the initial context page if it exists - const page = context.pages()[0] || (await context.newPage()); + await use(context); + await context.close(); + }, + async page({ context, extensionId }, use) { + // Re-use the initial context page if it exists + const page = context.pages()[0] || (await context.newPage()); - // Start off test from the extension console, and ensure it is done loading - const modsPage = new ModsPage(page, extensionId); - await modsPage.goto(); + // Start off test from the extension console, and ensure it is done loading + const modsPage = new ModsPage(page, extensionId); + await modsPage.goto(); - await use(page); - // The page is closed by the context fixture `.close` cleanup step - }, - async extensionId({ context }, use) { - const extensionId = await getExtensionId(context); - await use(extensionId); - }, -}); + await use(page); + // The page is closed by the context fixture `.close` cleanup step + }, + async extensionId({ context }, use) { + const extensionId = await getExtensionId(context); + await use(extensionId); + }, + }), +); export const { expect } = test; diff --git a/end-to-end-tests/tests/telemetry/errors.spec.ts b/end-to-end-tests/tests/telemetry/errors.spec.ts index 7419d5b8c2..cff34cf956 100644 --- a/end-to-end-tests/tests/telemetry/errors.spec.ts +++ b/end-to-end-tests/tests/telemetry/errors.spec.ts @@ -5,6 +5,13 @@ import { type Page, test as base } from "@playwright/test"; import { getBaseExtensionConsoleUrl } from "../../pageObjects/constants"; import { MV } from "../../env"; +test.use({ + additionalRequiredEnvVariables: [ + "DATADOG_CLIENT_TOKEN", + "DEV_EVENT_TELEMETRY", + ], +}); + test("can report application error to telemetry service", async ({ page, context, diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 3806018536..182122cf49 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -9,6 +9,7 @@ "../end-to-end-tests/auth.setup.ts", "../end-to-end-tests/env.ts", "../end-to-end-tests/fixtures/extensionBase.ts", + "../end-to-end-tests/fixtures/envSetup.ts", "../end-to-end-tests/pageObjects/constants.ts", "../end-to-end-tests/pageObjects/extensionConsole/localIntegrationsPage.ts", "../end-to-end-tests/pageObjects/extensionConsole/modsPage.ts",