diff --git a/README.md b/README.md index 16a099e..9ab187e 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ export default defineAuthConfig({ // If omitted, the CLI assumes your app is already running at `baseURL`. webServer: { command: "npm run dev", + // Optional: extra env for the server process (merged over process.env). + // env: { PORT: "3000" }, // Optional; defaults to baseURL when omitted. // url: "http://127.0.0.1:3000/login", }, diff --git a/examples/next-admin-auth/.env.example b/examples/next-admin-auth/.env.example index 097cf1c..c4f6c1a 100644 --- a/examples/next-admin-auth/.env.example +++ b/examples/next-admin-auth/.env.example @@ -2,3 +2,4 @@ AUTH_ADMIN_EMAIL=admin@example.com AUTH_ADMIN_PASSWORD=admin AUTH_USER_EMAIL=user@example.com AUTH_USER_PASSWORD=user +PLAYWRIGHT_KIT_EXAMPLE=next-admin-auth diff --git a/examples/next-admin-auth/package.json b/examples/next-admin-auth/package.json index 99fca28..83303b6 100644 --- a/examples/next-admin-auth/package.json +++ b/examples/next-admin-auth/package.json @@ -5,7 +5,7 @@ "dev": "next dev -p 3017", "build": "next build", "start": "next start -p 3017", - "auth:ensure": "playwright-kit auth ensure --dotenv", + "auth:ensure": "playwright-kit auth ensure", "pretest": "npm run auth:ensure", "test": "playwright test" }, @@ -21,7 +21,6 @@ "@types/node": "^22.10.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "dotenv": "^16.4.7", "playwright": "^1.50.1", "typescript": "^5.7.3" } diff --git a/examples/next-admin-auth/pages/api/env.ts b/examples/next-admin-auth/pages/api/env.ts new file mode 100644 index 0000000..c7a2b50 --- /dev/null +++ b/examples/next-admin-auth/pages/api/env.ts @@ -0,0 +1,8 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(_req: NextApiRequest, res: NextApiResponse): void { + res.status(200).json({ + PLAYWRIGHT_KIT_EXAMPLE: process.env.PLAYWRIGHT_KIT_EXAMPLE ?? null, + }); +} + diff --git a/examples/next-admin-auth/playwright.auth.config.ts b/examples/next-admin-auth/playwright.auth.config.ts index 8890149..31eb2ee 100644 --- a/examples/next-admin-auth/playwright.auth.config.ts +++ b/examples/next-admin-auth/playwright.auth.config.ts @@ -1,6 +1,9 @@ import { defineAuthConfig } from "@playwright-kit/auth"; +import { loadEnvConfig } from "@next/env"; import type { Page } from "playwright"; +loadEnvConfig(process.cwd()); + const baseURL = "http://127.0.0.1:3017"; async function login(page: Page, email: string, password: string): Promise { diff --git a/examples/next-admin-auth/tests/env.spec.ts b/examples/next-admin-auth/tests/env.spec.ts new file mode 100644 index 0000000..3c9f2cf --- /dev/null +++ b/examples/next-admin-auth/tests/env.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from "@playwright/test"; + +test("Next server exposes PLAYWRIGHT_KIT_EXAMPLE from env", async ({ request }) => { + const res = await request.get("/api/env"); + expect(res.ok()).toBeTruthy(); + await expect(res.json()).resolves.toEqual({ PLAYWRIGHT_KIT_EXAMPLE: "next-admin-auth" }); +}); + diff --git a/packages/auth/README.md b/packages/auth/README.md index 0992d1c..0f68c79 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -88,6 +88,8 @@ export default defineAuthConfig({ // If omitted, the CLI assumes your app is already running at `baseURL`. webServer: { command: "npm run dev", + // Optional: extra env for the server process (merged over process.env). + // env: { PORT: "3000" }, // Optional; defaults to baseURL when omitted. // url: "http://127.0.0.1:3000/login", }, diff --git a/packages/auth/src/__tests__/all.test.ts b/packages/auth/src/__tests__/all.test.ts index 233398e..d8a5120 100644 --- a/packages/auth/src/__tests__/all.test.ts +++ b/packages/auth/src/__tests__/all.test.ts @@ -1,4 +1,5 @@ import "./envMapping.test"; import "./argParsing.test"; import "./configFindAndLoad.test"; +import "./webServerEnv.test"; import "./lock.test"; diff --git a/packages/auth/src/__tests__/configFindAndLoad.test.ts b/packages/auth/src/__tests__/configFindAndLoad.test.ts index b9d3e80..4b2758b 100644 --- a/packages/auth/src/__tests__/configFindAndLoad.test.ts +++ b/packages/auth/src/__tests__/configFindAndLoad.test.ts @@ -50,7 +50,7 @@ test("loadAuthConfig allows webServer.url to be omitted when baseURL is set", as [ "export default {", " baseURL: 'http://127.0.0.1:3000',", - " webServer: { command: 'node', args: ['server.js'] },", + " webServer: { command: 'node', args: ['server.js'], env: { FOO: 'bar' } },", " profiles: {", " admin: {", " validateUrl: '/',", @@ -68,6 +68,7 @@ test("loadAuthConfig allows webServer.url to be omitted when baseURL is set", as assert.equal(loaded.configFilePath, configPath); assert.ok(loaded.config.webServer); assert.equal(loaded.config.webServer.command, "node"); + assert.deepEqual(loaded.config.webServer.env, { FOO: "bar" }); }); test("loadAuthConfig requires baseURL when webServer.url is omitted", async () => { @@ -97,6 +98,34 @@ test("loadAuthConfig requires baseURL when webServer.url is omitted", async () = ); }); +test("loadAuthConfig rejects non-string webServer.env values", async () => { + const root = await makeTempDir(); + const configPath = path.join(root, "playwright.auth.config.ts"); + await fs.writeFile( + configPath, + [ + "export default {", + " baseURL: 'http://127.0.0.1:3000',", + " webServer: { command: 'node', url: 'http://127.0.0.1:3000', env: { PORT: 3000 } },", + " profiles: {", + " admin: {", + " validateUrl: '/',", + " async login() {},", + " async validate() { return { ok: true }; },", + " },", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + + await assert.rejects( + () => loadAuthConfig({ cwd: root }), + (error) => isUserError(error), + ); +}); + test("loadAuthConfig throws a user error when config is missing", async () => { const root = await makeTempDir(); await assert.rejects( diff --git a/packages/auth/src/__tests__/webServerEnv.test.ts b/packages/auth/src/__tests__/webServerEnv.test.ts new file mode 100644 index 0000000..5d934ef --- /dev/null +++ b/packages/auth/src/__tests__/webServerEnv.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { withWebServer } from "../cli/webServer"; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "playwright-kit-auth-webserver-env-")); +} + +function getFreePort(): number { + return 20_000 + Math.floor(Math.random() * 10_000); +} + +test("withWebServer passes webServer.env to the spawned process", async () => { + const root = await makeTempDir(); + const port = getFreePort(); + const serverPath = path.join(root, "server.mjs"); + await fs.writeFile( + serverPath, + [ + "import http from 'node:http';", + "", + "const port = Number(process.argv[2]);", + "const server = http.createServer((req, res) => {", + " if (req.url === '/env') {", + " res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });", + " res.end(JSON.stringify({ PLAYWRIGHT_KIT_EXAMPLE: process.env.PLAYWRIGHT_KIT_EXAMPLE ?? null }));", + " return;", + " }", + " res.writeHead(404);", + " res.end('not found');", + "});", + "", + "server.listen(port, '127.0.0.1');", + "process.on('SIGINT', () => server.close(() => process.exit(0)));", + "", + ].join("\n"), + "utf8", + ); + + const url = `http://127.0.0.1:${port}/env`; + await withWebServer( + { + command: "node", + args: [serverPath, String(port)], + url, + timeoutMs: 10_000, + reuseExisting: false, + env: { PLAYWRIGHT_KIT_EXAMPLE: "vite-react-auth" }, + }, + async () => { + const res = await fetch(url); + assert.equal(res.status, 200); + const data: unknown = await res.json(); + assert.deepEqual(data, { PLAYWRIGHT_KIT_EXAMPLE: "vite-react-auth" }); + }, + ); +}); diff --git a/packages/auth/src/cli/args.ts b/packages/auth/src/cli/args.ts index 3c8d60d..7cf4137 100644 --- a/packages/auth/src/cli/args.ts +++ b/packages/auth/src/cli/args.ts @@ -13,6 +13,7 @@ export interface WebServerArgs { url: string; timeoutMs: number; reuseExisting: boolean; + env?: Record; } export interface DotenvArgs { diff --git a/packages/auth/src/cli/main.ts b/packages/auth/src/cli/main.ts index 3a619fa..44fcef9 100644 --- a/packages/auth/src/cli/main.ts +++ b/packages/auth/src/cli/main.ts @@ -32,25 +32,25 @@ async function run(argv: string[]): Promise { const loaded = await loadAuthConfig({ cwd: process.cwd(), configPath: parsed.configPath }); console.log(`auth: config ${loaded.configFilePath}`); - const resolvedWebServer: WebServerArgs | undefined = - parsed.webServer ?? - (loaded.config.webServer - ? { - command: loaded.config.webServer.command, - args: loaded.config.webServer.args ?? [], - url: - loaded.config.webServer.url ?? - loaded.config.baseURL ?? - (() => { - throw createUserError( - `Auth config webServer.url is missing and baseURL is not set.`, - ); - })(), - timeoutMs: loaded.config.webServer.timeoutMs ?? DEFAULT_WEB_SERVER_TIMEOUT_MS, - reuseExisting: - loaded.config.webServer.reuseExisting ?? DEFAULT_WEB_SERVER_REUSE_EXISTING, - } - : undefined); + const webServerFromConfig: WebServerArgs | undefined = loaded.config.webServer + ? { + command: loaded.config.webServer.command, + args: loaded.config.webServer.args ?? [], + url: + loaded.config.webServer.url ?? + loaded.config.baseURL ?? + (() => { + throw createUserError(`Auth config webServer.url is missing and baseURL is not set.`); + })(), + timeoutMs: loaded.config.webServer.timeoutMs ?? DEFAULT_WEB_SERVER_TIMEOUT_MS, + reuseExisting: loaded.config.webServer.reuseExisting ?? DEFAULT_WEB_SERVER_REUSE_EXISTING, + env: loaded.config.webServer.env, + } + : undefined; + + const resolvedWebServer: WebServerArgs | undefined = parsed.webServer + ? { ...parsed.webServer, env: parsed.webServer.env ?? webServerFromConfig?.env } + : webServerFromConfig; if (parsed.kind === "setup") { const result = await withWebServer(resolvedWebServer, async () => diff --git a/packages/auth/src/cli/webServer.ts b/packages/auth/src/cli/webServer.ts index 1017fe8..13dd502 100644 --- a/packages/auth/src/cli/webServer.ts +++ b/packages/auth/src/cli/webServer.ts @@ -231,13 +231,14 @@ export async function withWebServer( ? [quoteCmdArg(command), ...webServer.args.map(quoteCmdArg)].join(" ") : command; const argsForSpawn = forceQuotedCommandLine ? [] : webServer.args; + const envForSpawn = webServer.env ? { ...process.env, ...webServer.env } : process.env; let child: ReturnType; try { child = spawn(commandForSpawn, argsForSpawn, { stdio: "inherit", shell: useShell, - env: process.env, + env: envForSpawn, detached: process.platform !== "win32", windowsHide: true, }); diff --git a/packages/auth/src/config/loadAuthConfig.ts b/packages/auth/src/config/loadAuthConfig.ts index 4b1da01..db53ac0 100644 --- a/packages/auth/src/config/loadAuthConfig.ts +++ b/packages/auth/src/config/loadAuthConfig.ts @@ -124,6 +124,20 @@ function assertAuthConfig(config: unknown): asserts config is AuthConfig { ) { throw createUserError(`Auth config "webServer.args" must be an array of strings.`); } + if (config.webServer.env !== undefined) { + if (!isObject(config.webServer.env)) { + throw createUserError( + `Auth config "webServer.env" must be an object (key/value pairs) with string values.`, + ); + } + for (const [key, value] of Object.entries(config.webServer.env)) { + if (typeof value !== "string") { + throw createUserError( + `Auth config "webServer.env.${key}" must be a string (got ${typeof value}).`, + ); + } + } + } } if ( diff --git a/packages/auth/src/config/types.ts b/packages/auth/src/config/types.ts index 2484708..4599717 100644 --- a/packages/auth/src/config/types.ts +++ b/packages/auth/src/config/types.ts @@ -17,6 +17,11 @@ export interface AuthWebServerConfig { url?: string; timeoutMs?: number; reuseExisting?: boolean; + /** + * Extra environment variables for the web server process. + * Merged as `{ ...process.env, ...env }`. + */ + env?: Record; } export interface AuthCredentials {