diff --git a/.github/workflows/canister-tests.yml b/.github/workflows/canister-tests.yml index 055d2e783b..f3723b5afe 100644 --- a/.github/workflows/canister-tests.yml +++ b/.github/workflows/canister-tests.yml @@ -57,7 +57,7 @@ jobs: II_FETCH_ROOT_KEY: 1 II_DUMMY_CAPTCHA: 1 II_DUMMY_AUTH: 0 - II_DEV_CSP: 0 + II_DEV_CSP: 1 # Everything disabled, used by third party developers who only care # about the login flow @@ -582,6 +582,7 @@ jobs: needs: [cached-build, test-app-build] strategy: matrix: + browser: ["chrome", "safari"] device: ["desktop", "mobile"] shard: ["1_3", "2_3", "3_3"] # Make sure that one failing test does not cancel all other matrix jobs @@ -589,7 +590,7 @@ jobs: env: # Suffix used for tagging artifacts - artifact_suffix: next-${{ matrix.device }}-${{ matrix.shard }} + artifact_suffix: next-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.shard }} # OpenID configuration for provider in /src/test_openid_provider openid_name: Test OpenID openid_logo: | @@ -615,7 +616,12 @@ jobs: run: npm ci --no-audit --no-fund - name: Install Playwright Browsers - run: npx playwright install chromium + run: | + if [ "${{ matrix.browser }}" = "chrome" ]; then + npx playwright install --with-deps chromium + else + npx playwright install --with-deps webkit + fi - uses: dfinity/setup-dfx@e50c04f104ee4285ec010f10609483cf41e4d365 @@ -666,7 +672,7 @@ jobs: echo "dev_server_pid=$dev_server_pid" >> "$GITHUB_OUTPUT" - run: | - npx playwright test --project ${{ matrix.device }} --workers 1 --shard=$(tr <<<'${{ matrix.shard }}' -s _ /) + npx playwright test --project ${{ matrix.browser }}-${{ matrix.device }} --workers 1 --shard=$(tr <<<'${{ matrix.shard }}' -s _ /) - name: Stop dfx if: ${{ always() }} diff --git a/HACKING.md b/HACKING.md index f90c169a62..1d0d5ec56a 100644 --- a/HACKING.md +++ b/HACKING.md @@ -136,6 +136,22 @@ npx playwright test --ui > II_CAPTCHA=enabled npm run test:e2e > ``` +**Writing new Playwright E2E tests:** + +When creating new E2E Playwright tests, always import `test` and `expect` from the custom fixtures file: + +```typescript +// ✅ Correct +import { test, expect } from "./fixtures"; +// or from subdirectories: +import { test, expect } from "../fixtures"; + +// ❌ Wrong - will fail ESLint +import { test, expect } from "@playwright/test"; +``` + +The custom fixtures provide automatic host routing for Safari/WebKit tests, which don't support `--host-resolver-rules`. ESLint will enforce this pattern and show an error if you try to import directly from `@playwright/test`. + We autoformat our code using `prettier`. Running `npm run format` formats all files in the frontend. If you open a PR that isn't formatted according to `prettier`, CI will automatically add a formatting commit to your PR. diff --git a/eslint.config.js b/eslint.config.js index b627db37c5..72b98e313b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -60,4 +60,22 @@ export default ts.config( { ignores: ["src/frontend/src/lib/generated/*", "src/showcase/.astro/*"], }, + { + files: ["src/frontend/tests/e2e-playwright/**/*.spec.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@playwright/test", + importNames: ["test", "expect"], + message: + "Import 'test' and 'expect' from './fixtures' or '../fixtures' instead to use custom test fixtures with host routing for Safari/WebKit.", + }, + ], + }, + ], + }, + }, ); diff --git a/playwright.config.ts b/playwright.config.ts index fa76e390a2..1a4453591f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -30,13 +30,17 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", + + /* Ignore HTTPS errors, which is needed for the self-signed certificate. */ + // It doesn't seem to fix it + // ignoreHTTPSErrors: true, }, timeout: 60000, /* Configure projects for major browsers */ projects: [ { - name: "desktop", + name: "chrome-desktop", use: { ...devices["Desktop Chrome"], launchOptions: { @@ -48,7 +52,7 @@ export default defineConfig({ }, }, { - name: "mobile", + name: "chrome-mobile", use: { ...devices["Pixel 5"], launchOptions: { @@ -59,5 +63,23 @@ export default defineConfig({ }, }, }, + { + name: "safari-desktop", + use: { + ...devices["Desktop Safari"], + launchOptions: { + args: ["--ignore-certificate-errors"], + }, + }, + }, + { + name: "safari-mobile", + use: { + ...devices["iPhone 12"], + launchOptions: { + args: ["--ignore-certificate-errors"], + }, + }, + }, ], }); diff --git a/src/frontend/tests/e2e-playwright/authorize/account.spec.ts b/src/frontend/tests/e2e-playwright/authorize/account.spec.ts index 1899faaf94..432575ff10 100644 --- a/src/frontend/tests/e2e-playwright/authorize/account.spec.ts +++ b/src/frontend/tests/e2e-playwright/authorize/account.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { authorize, createIdentity, dummyAuth } from "../utils"; test("Create and authorize with additional account", async ({ page }) => { diff --git a/src/frontend/tests/e2e-playwright/authorize/alternativeOrigins.spec.ts b/src/frontend/tests/e2e-playwright/authorize/alternativeOrigins.spec.ts index a907ee374b..4f31f2e891 100644 --- a/src/frontend/tests/e2e-playwright/authorize/alternativeOrigins.spec.ts +++ b/src/frontend/tests/e2e-playwright/authorize/alternativeOrigins.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "../fixtures"; import { dummyAuth, II_URL, diff --git a/src/frontend/tests/e2e-playwright/authorize/continue.spec.ts b/src/frontend/tests/e2e-playwright/authorize/continue.spec.ts index 72e8d77c87..27830bda2b 100644 --- a/src/frontend/tests/e2e-playwright/authorize/continue.spec.ts +++ b/src/frontend/tests/e2e-playwright/authorize/continue.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { authorize, authorizeWithUrl, diff --git a/src/frontend/tests/e2e-playwright/authorize/delegationTtl.spec.ts b/src/frontend/tests/e2e-playwright/authorize/delegationTtl.spec.ts index 70d2b47fa2..fb987a3313 100644 --- a/src/frontend/tests/e2e-playwright/authorize/delegationTtl.spec.ts +++ b/src/frontend/tests/e2e-playwright/authorize/delegationTtl.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { dummyAuth, II_URL, TEST_APP_URL } from "../utils"; test("Delegation maxTimeToLive: 1 min", async ({ page }) => { diff --git a/src/frontend/tests/e2e-playwright/authorize/index.spec.ts b/src/frontend/tests/e2e-playwright/authorize/index.spec.ts index 9a1e5233df..a00bb13fb7 100644 --- a/src/frontend/tests/e2e-playwright/authorize/index.spec.ts +++ b/src/frontend/tests/e2e-playwright/authorize/index.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { authorize, authorizeWithUrl, diff --git a/src/frontend/tests/e2e-playwright/authorize/migration.spec.ts b/src/frontend/tests/e2e-playwright/authorize/migration.spec.ts index ec75470d9b..cf9823efde 100644 --- a/src/frontend/tests/e2e-playwright/authorize/migration.spec.ts +++ b/src/frontend/tests/e2e-playwright/authorize/migration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import type Protocol from "devtools-protocol"; import { addCredentialToVirtualAuthenticator, @@ -15,6 +15,11 @@ import { isNullish } from "@dfinity/utils"; const TEST_USER_NAME = "Test User"; test.describe("Migration from an app", () => { + test.skip( + ({ browserName }) => browserName === "webkit", + "Migration test not supported on Safari because it uses virtual authenticators which are not supported.", + ); + test("User can migrate a legacy identity", async ({ page }) => { const auth = dummyAuth(); let credential: Protocol.WebAuthn.Credential | undefined; diff --git a/src/frontend/tests/e2e-playwright/authorize/postMessages.spec.ts b/src/frontend/tests/e2e-playwright/authorize/postMessages.spec.ts index fca0d4f4e6..ece8aab538 100644 --- a/src/frontend/tests/e2e-playwright/authorize/postMessages.spec.ts +++ b/src/frontend/tests/e2e-playwright/authorize/postMessages.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { createNewIdentityInII, dummyAuth, diff --git a/src/frontend/tests/e2e-playwright/dashboard/addPasskeys.spec.ts b/src/frontend/tests/e2e-playwright/dashboard/addPasskeys.spec.ts index faf69c2d2b..1d728c8b85 100644 --- a/src/frontend/tests/e2e-playwright/dashboard/addPasskeys.spec.ts +++ b/src/frontend/tests/e2e-playwright/dashboard/addPasskeys.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { addPasskeyCurrentDevice, clearStorage, diff --git a/src/frontend/tests/e2e-playwright/dashboard/index.spec.ts b/src/frontend/tests/e2e-playwright/dashboard/index.spec.ts index d1ac76de9d..8041d7470c 100644 --- a/src/frontend/tests/e2e-playwright/dashboard/index.spec.ts +++ b/src/frontend/tests/e2e-playwright/dashboard/index.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { clearStorage, createIdentity, dummyAuth, II_URL } from "../utils"; const TEST_USER_NAME = "Test User"; diff --git a/src/frontend/tests/e2e-playwright/dashboard/migration.spec.ts b/src/frontend/tests/e2e-playwright/dashboard/migration.spec.ts index 860098f4f0..f04cd9dd6a 100644 --- a/src/frontend/tests/e2e-playwright/dashboard/migration.spec.ts +++ b/src/frontend/tests/e2e-playwright/dashboard/migration.spec.ts @@ -1,4 +1,5 @@ -import { expect, Page, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; +import type { Page } from "@playwright/test"; import { addVirtualAuthenticator, clearStorage, @@ -38,6 +39,11 @@ const upgradeLegacyIdentity = async ( }; test.describe("Migration", () => { + test.skip( + ({ browserName }) => browserName === "webkit", + "Migration test not supported on Safari because it uses virtual authenticators which are not supported.", + ); + test("User can migrate a legacy identity", async ({ page }) => { // Step 1: Create a legacy identity await page.goto(LEGACY_II_URL); diff --git a/src/frontend/tests/e2e-playwright/dashboard/removePasskey.spec.ts b/src/frontend/tests/e2e-playwright/dashboard/removePasskey.spec.ts index 95556a1cfb..d5195f3321 100644 --- a/src/frontend/tests/e2e-playwright/dashboard/removePasskey.spec.ts +++ b/src/frontend/tests/e2e-playwright/dashboard/removePasskey.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { clearStorage, createNewIdentityInII, diff --git a/src/frontend/tests/e2e-playwright/dashboard/renamePasskeys.spec.ts b/src/frontend/tests/e2e-playwright/dashboard/renamePasskeys.spec.ts index 266ce484bb..7e151fe0c3 100644 --- a/src/frontend/tests/e2e-playwright/dashboard/renamePasskeys.spec.ts +++ b/src/frontend/tests/e2e-playwright/dashboard/renamePasskeys.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../fixtures"; import { clearStorage, createNewIdentityInII, diff --git a/src/frontend/tests/e2e-playwright/fixtures.ts b/src/frontend/tests/e2e-playwright/fixtures.ts new file mode 100644 index 0000000000..d3db0f25e4 --- /dev/null +++ b/src/frontend/tests/e2e-playwright/fixtures.ts @@ -0,0 +1,68 @@ +import { test as base, expect } from "@playwright/test"; + +/** + * Custom test fixture that automatically applies host resolution routing + * to redirect all non-localhost requests to localhost:5173 + * + * ⚠️ IMPORTANT: All E2E test files MUST import { test, expect } from this file + * instead of from '@playwright/test' to ensure proper host routing for Safari/WebKit. + * + * @example + * // ✅ Correct + * import { test, expect } from "./fixtures"; + * // or from subdirectories: + * import { test, expect } from "../fixtures"; + * + * // ❌ Wrong - will fail ESLint + * import { test, expect } from "@playwright/test"; + */ +export const test = base.extend({ + page: async ({ page }, use) => { + const browserName = page.context().browser()?.browserType().name(); + + // Safari doesn't support --host-resolver-rules, so we need page routing for Safari + // Chromium browsers will use host-resolver-rules which preserves Host headers better + if (browserName === "webkit") { + await page.context().route("**/*", (route) => { + // Should map the config in `vite.config.ts` + const hostToCanisterName: Record = { + ["id.ai"]: "internet_identity", + ["identity.ic0.app"]: "internet_identity", + ["identity.internetcomputer.org"]: "internet_identity", + ["nice-name.com"]: "test_app", + }; + + const req = route.request(); + const urlStr = req.url(); + + let url: URL; + try { + url = new URL(urlStr); + } catch { + return route.continue(); + } + + if (url.hostname.includes("localhost")) { + return route.continue(); + } + + const canister_name = hostToCanisterName[url.hostname]; + if (canister_name === undefined) { + return route.continue(); + } + const newUrl = `https://${canister_name}.localhost:5173${url.pathname}${url.search}`; + return route.continue({ + url: newUrl, + // The vite server uses the Host header to determine where the redirect the request. + headers: { ...req.headers(), Host: url.hostname }, + }); + }); + } + + // Use the page with routing applied + await use(page); + }, +}); + +// Re-export expect for convenience +export { expect }; diff --git a/src/frontend/tests/e2e-playwright/index.spec.ts b/src/frontend/tests/e2e-playwright/index.spec.ts index 4131274d71..0d10f5f9ad 100644 --- a/src/frontend/tests/e2e-playwright/index.spec.ts +++ b/src/frontend/tests/e2e-playwright/index.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./fixtures"; import { clearStorage, createIdentity, dummyAuth, II_URL } from "./utils"; // This is chosen on purpose to exhibit a JWT token that is encoded in base64url but cannot diff --git a/src/internet_identity/src/http.rs b/src/internet_identity/src/http.rs index cac29d0ff6..11d2afb1a7 100644 --- a/src/internet_identity/src/http.rs +++ b/src/internet_identity/src/http.rs @@ -259,10 +259,19 @@ fn content_security_policy_header( }; let connect_src = "'self' https:"; + let script_src = format!("{strict_dynamic} 'unsafe-inline' 'unsafe-eval' https:"); + let style_src = "'self' 'unsafe-inline'"; + let img_src = "'self' data: https://*.googleusercontent.com"; - // Allow connecting via http for development purposes + // Allow connecting via http and localhost (including subdomains) for development purposes #[cfg(feature = "dev_csp")] - let connect_src = format!("{connect_src} http:"); + let connect_src = format!("{connect_src} http: http://localhost:* http://*.localhost:*"); + #[cfg(feature = "dev_csp")] + let script_src = format!("{script_src} http: http://localhost:* http://*.localhost:*"); + #[cfg(feature = "dev_csp")] + let style_src = format!("{style_src} http: http://localhost:* http://*.localhost:*"); + #[cfg(feature = "dev_csp")] + let img_src = format!("{img_src} http: http://localhost:* http://*.localhost:*"); // Allow related origins to embed one another for cross-domain WebAuthn let frame_src = maybe_related_origins @@ -273,12 +282,12 @@ fn content_security_policy_header( let csp = format!( "default-src 'none';\ connect-src {connect_src};\ - img-src 'self' data: https://*.googleusercontent.com;\ - script-src {strict_dynamic} 'unsafe-inline' 'unsafe-eval' https:;\ + img-src {img_src};\ + script-src {script_src};\ base-uri 'none';\ form-action 'none';\ - style-src 'self' 'unsafe-inline';\ - style-src-elem 'self' 'unsafe-inline';\ + style-src {style_src};\ + style-src-elem {style_src};\ font-src 'self';\ frame-ancestors {frame_src};\ frame-src {frame_src};"