Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ You are contributing to an open-source TypeScript project called `playwright-kit
- Favor clarity over cleverness.
- Follow Playwright and Node.js best practices.
- TypeScript `strict` mode (no implicit `any`; prefer `unknown` over `any`).
- Avoid dynamic import queries (e.g. `typeof import("@playwright/test")["test"]`); prefer explicit `import type { ... }` and build types from them.
- Avoid `import("...")` / `require("...")` for fixed module specifiers; prefer top-level `import` / `import type` (exception: loading user-provided config files by path/URL).
- Keep public APIs small and well-documented; avoid breaking changes unless explicitly requested.

## When uncertain
- If something is unclear, ask first or leave `TODO:` comments instead of guessing.

1 change: 0 additions & 1 deletion examples/vite-react-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"dev": "vite --port 4173",
"build": "vite build",
"preview": "vite preview --port 4173",
"setup:env": "node -e \"const fs=require('node:fs'); if(!fs.existsSync('.env') && fs.existsSync('.env.example')) fs.copyFileSync('.env.example','.env');\"",
"auth:ensure": "playwright-kit auth ensure --dotenv",
"pretest:e2e": "npm run auth:ensure",
"test:e2e": "playwright test"
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"scripts": {
"build": "node scripts/rm-dist.mjs && tsc -p tsconfig.build.json",
"prepack": "npm run build",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"typecheck": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.type-tests.json",
"test": "node --test --import tsx src/__tests__/all.test.ts"
},
"peerDependencies": {
Expand All @@ -57,6 +57,7 @@
}
},
"dependencies": {
"dotenv": "^16.4.7",
"tsx": "^4.20.0"
},
"devDependencies": {
Expand Down
10 changes: 1 addition & 9 deletions packages/auth/src/cli/dotenv.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "node:path";
import dotenv from "dotenv";

import { createUserError } from "../internal/userError";

Expand All @@ -9,15 +10,6 @@ export async function maybeLoadDotenv(options: {
}): Promise<void> {
if (!options.enabled) return;

let dotenv: typeof import("dotenv");
try {
dotenv = (await import("dotenv")) as typeof import("dotenv");
} catch {
throw createUserError(
`--dotenv requires "dotenv" to be installed in your project (npm i -D dotenv).`,
);
}

const resolvedPath = options.dotenvPath
? path.resolve(options.cwd, options.dotenvPath)
: undefined;
Expand Down
138 changes: 61 additions & 77 deletions packages/auth/src/fixtures/createAuthTest.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
import fs from "node:fs";
import path from "node:path";

import type { Expect, TestType } from "@playwright/test";
import { expect as playwrightExpect, test as playwrightTest } from "@playwright/test";
import type {
PlaywrightTestArgs,
PlaywrightTestOptions,
PlaywrightWorkerArgs,
PlaywrightWorkerOptions,
TestInfo,
TestType,
} from "@playwright/test";

type BaseTestArgs = PlaywrightTestArgs & PlaywrightTestOptions;
type BaseWorkerArgs = PlaywrightWorkerArgs & PlaywrightWorkerOptions;
type StorageStateOption = PlaywrightTestOptions["storageState"];

type DefaultBaseTest = typeof playwrightTest;
type DefaultExpect = typeof playwrightExpect;

type AuthFixtures = {
auth: string;
_authStatePath: string;
storageState: StorageStateOption;
};

type AnyFixtures = object;
type AnyTestType = TestType<AnyFixtures, AnyFixtures>;
type AnyExpect = Expect<unknown>;
type AuthTestArgs = BaseTestArgs & BaseWorkerArgs & AuthFixtures;
export type AuthTestBody = (args: AuthTestArgs, testInfo: TestInfo) => Promise<void> | void;

export interface CreateAuthTestOptions {
statesDir?: string;
defaultProfile?: string;
baseTest?: AnyTestType;
baseExpect?: AnyExpect;
}

type PlaywrightTestFn = (...args: unknown[]) => unknown;
type AuthWrappedTest = TestType<BaseTestArgs & AuthFixtures, BaseWorkerArgs>;

export type AuthTest = AnyTestType & {
withAuth(profile?: string): AnyTestType;
auth(profile: string, title: string, fn: PlaywrightTestFn): void;
auth(title: string, fn: PlaywrightTestFn): void;
export type AuthTest = AuthWrappedTest & {
withAuth(profile?: string): AuthWrappedTest;
auth(profile: string, title: string, fn: AuthTestBody): void;
auth(title: string, fn: AuthTestBody): void;
};

export type AuthTestWithExpect = AuthTest & { expect: AnyExpect };
export type AuthTestWithExpect = AuthTest & { expect: DefaultExpect };

function resolveStatePath(options: { statesDir: string; profile: string }): string {
const dir = path.isAbsolute(options.statesDir)
Expand All @@ -43,21 +56,7 @@ function assertStateFileReadable(statePath: string, profile: string): void {
JSON.parse(raw);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Invalid auth state JSON for profile "${profile}" at "${statePath}": ${message}`,
);
}
}

function loadPlaywrightTestModule(): typeof import("@playwright/test") {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("@playwright/test") as typeof import("@playwright/test");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`@playwright-kit/auth wrapper requires @playwright/test to be installed (peer dependency). ${message}`,
);
throw new Error(`Invalid auth state JSON for profile "${profile}" at "${statePath}": ${message}`);
}
}

Expand All @@ -67,27 +66,22 @@ export interface AuthTestOptions {
/** Alias for statesDir (kept for ergonomics). */
stateDir?: string;
defaultProfile: string;
baseTest?: AnyTestType;
baseExpect?: AnyExpect;
baseTest?: DefaultBaseTest;
baseExpect?: DefaultExpect;
}

export function authTest(options: AuthTestOptions): AuthTestWithExpect {
const loaded = loadPlaywrightTestModule();
const baseTest = options.baseTest ?? loaded.test;
const expect = options.baseExpect ?? loaded.expect;
const baseTest = options.baseTest ?? playwrightTest;
const expect = options.baseExpect ?? playwrightExpect;

const statesDir = options.statesDir ?? options.stateDir ?? ".auth";
const defaultProfile = options.defaultProfile;

const testBase = baseTest.extend<{
auth: string;
_authStatePath: string;
}>({
auth: [defaultProfile, { option: true }],
_authStatePath: async (
{ auth }: { auth: string },
use: (value: string) => Promise<void>,
) => {
const authOption: [string, { option: true }] = [defaultProfile, { option: true }];

const testBase = baseTest.extend<AuthFixtures>({
auth: authOption,
_authStatePath: async ({ auth }, use) => {
if (!auth) {
throw new Error(
`No auth profile selected. Set defaultProfile in authTest({ defaultProfile }) or use test.use({ auth: "<profile>" }).`,
Expand All @@ -99,63 +93,52 @@ export function authTest(options: AuthTestOptions): AuthTestWithExpect {
},
// Override Playwright's built-in `storageState` option fixture so role switching
// composes with existing `test.use({ ...contextOptions })` patterns.
storageState: async (
{ _authStatePath }: { _authStatePath: string },
use: (value: string) => Promise<void>,
) => {
storageState: async ({ _authStatePath }, use) => {
await use(_authStatePath);
},
} as unknown as Parameters<typeof baseTest.extend>[0]);
});

const withAuth = (profile?: string): AnyTestType => {
const withAuth = (profile?: string): AuthWrappedTest => {
const selectedProfile = profile ?? defaultProfile;
const derived = testBase.extend({});
derived.use({ auth: selectedProfile });
return derived as unknown as AnyTestType;
return derived;
};

const auth: AuthTest["auth"] = (
a: string,
b: string | PlaywrightTestFn,
c?: PlaywrightTestFn,
) => {
const auth: AuthTest["auth"] = (a: string, b: string | AuthTestBody, c?: AuthTestBody) => {
if (typeof b === "function") {
const title = a;
const fn = b;
(testBase as unknown as (title: string, fn: PlaywrightTestFn) => void)(title, fn);
testBase(title, fn);
return;
}

const profile = a;
const title = b;
const fn = c;
if (!fn) {
throw new Error(`test.auth(profile, title, fn) requires a test function.`);
}
if (!fn) throw new Error(`test.auth(profile, title, fn) requires a test function.`);

const derived = testBase.extend({});
derived.use({ auth: profile });
(derived as unknown as (title: string, fn: PlaywrightTestFn) => void)(title, fn);
derived(title, fn);
};

const test = testBase as unknown as AuthTestWithExpect;
test.withAuth = withAuth;
test.auth = auth;
test.expect = expect;
return test;
return Object.assign(testBase, { withAuth, auth, expect });
}

export interface CreateAuthTestOptions {
statesDir?: string;
defaultProfile?: string;
baseTest?: DefaultBaseTest;
baseExpect?: DefaultExpect;
}

export function createAuthTest(options: CreateAuthTestOptions = {}): {
test: AuthTest;
expect: AnyExpect;
expect: DefaultExpect;
} {
let baseTest = options.baseTest;
let baseExpect = options.baseExpect;

if (!baseTest || !baseExpect) {
const loaded = loadPlaywrightTestModule();
baseTest = baseTest ?? loaded.test;
baseExpect = baseExpect ?? loaded.expect;
}
const baseTest = options.baseTest ?? playwrightTest;
const baseExpect = options.baseExpect ?? playwrightExpect;

const defaultProfile = options.defaultProfile;
if (!defaultProfile) {
Expand All @@ -173,3 +156,4 @@ export function createAuthTest(options: CreateAuthTestOptions = {}): {

return { test, expect: test.expect };
}

3 changes: 2 additions & 1 deletion packages/auth/src/runner/setupProfileState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { resolveProfileCredentials } from "../credentials/resolveCredentials";
import { resolveFailuresDir, resolveStatePath, resolveStatesDir } from "../state/paths";
import { writeFileAtomic } from "../state/writeStorageState";

import { chromium, firefox, webkit } from "playwright";

import { createRunId, writeFailureArtifacts } from "./artifacts";
import { mergeLaunchOptions } from "./mergeLaunchOptions";
import { resolveBaseURL, resolveValidateUrl } from "./resolveUrls";
Expand Down Expand Up @@ -33,7 +35,6 @@ export async function setupProfileState(options: {
env: options.env,
});

const { chromium, firefox, webkit } = await import("playwright");
let browserType = chromium;
if (browserName === "firefox") {
browserType = firefox;
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/src/runner/validateProfileState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { AuthConfig, AuthProfileConfig } from "../config/types";
import { resolveFailuresDir, resolveStatePath, resolveStatesDir } from "../state/paths";
import { readStorageStateJson } from "../state/readStorageState";

import { chromium, firefox, webkit } from "playwright";

import { createRunId, writeFailureArtifacts } from "./artifacts";
import { mergeLaunchOptions } from "./mergeLaunchOptions";
import { resolveBaseURL, resolveValidateUrl } from "./resolveUrls";
Expand Down Expand Up @@ -41,7 +43,6 @@ export async function validateProfileState(options: {
const baseURL = resolveBaseURL(options.config, options.profile);
const validateUrl = resolveValidateUrl(options.config, options.profile);

const { chromium, firefox, webkit } = await import("playwright");
let browserType = chromium;
if (browserName === "firefox") {
browserType = firefox;
Expand Down
9 changes: 9 additions & 0 deletions packages/auth/tsconfig.type-tests.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "type-tests/**/*.ts"],
"exclude": ["src/__tests__/**", "dist/**"]
}
15 changes: 15 additions & 0 deletions packages/auth/type-tests/authTest-page-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Page } from "@playwright/test";

import { authTest } from "../src/fixtures/createAuthTest";

type IsAny<T> = 0 extends 1 & T ? true : false;
type Assert<T extends true> = T;

const test = authTest({ defaultProfile: "user" });

test("page is typed", async ({ page }) => {
type _pageIsNotAny = Assert<IsAny<typeof page> extends false ? true : false>;
const _page: Page = page;
void _page;
});