@pwtap/create 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/core-manifest.json +50 -0
  2. package/dist/commands/add.d.ts +8 -0
  3. package/dist/commands/add.d.ts.map +1 -0
  4. package/dist/commands/add.js +9 -0
  5. package/dist/commands/add.js.map +1 -0
  6. package/dist/commands/create.d.ts +12 -0
  7. package/dist/commands/create.d.ts.map +1 -0
  8. package/dist/commands/create.js +114 -0
  9. package/dist/commands/create.js.map +1 -0
  10. package/dist/commands/remove.d.ts +7 -0
  11. package/dist/commands/remove.d.ts.map +1 -0
  12. package/dist/commands/remove.js +9 -0
  13. package/dist/commands/remove.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +55 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/injectors/assets.d.ts +6 -0
  19. package/dist/injectors/assets.d.ts.map +1 -0
  20. package/dist/injectors/assets.js +53 -0
  21. package/dist/injectors/assets.js.map +1 -0
  22. package/dist/injectors/envJson.d.ts +6 -0
  23. package/dist/injectors/envJson.d.ts.map +1 -0
  24. package/dist/injectors/envJson.js +39 -0
  25. package/dist/injectors/envJson.js.map +1 -0
  26. package/dist/injectors/fixturesBarrel.d.ts +11 -0
  27. package/dist/injectors/fixturesBarrel.d.ts.map +1 -0
  28. package/dist/injectors/fixturesBarrel.js +66 -0
  29. package/dist/injectors/fixturesBarrel.js.map +1 -0
  30. package/dist/injectors/packageJson.d.ts +6 -0
  31. package/dist/injectors/packageJson.d.ts.map +1 -0
  32. package/dist/injectors/packageJson.js +25 -0
  33. package/dist/injectors/packageJson.js.map +1 -0
  34. package/dist/injectors/pwConfig.d.ts +10 -0
  35. package/dist/injectors/pwConfig.d.ts.map +1 -0
  36. package/dist/injectors/pwConfig.js +40 -0
  37. package/dist/injectors/pwConfig.js.map +1 -0
  38. package/dist/manifest.d.ts +65 -0
  39. package/dist/manifest.d.ts.map +1 -0
  40. package/dist/manifest.js +22 -0
  41. package/dist/manifest.js.map +1 -0
  42. package/dist/plugin-apply.d.ts +13 -0
  43. package/dist/plugin-apply.d.ts.map +1 -0
  44. package/dist/plugin-apply.js +89 -0
  45. package/dist/plugin-apply.js.map +1 -0
  46. package/dist/prompts.d.ts +22 -0
  47. package/dist/prompts.d.ts.map +1 -0
  48. package/dist/prompts.js +65 -0
  49. package/dist/prompts.js.map +1 -0
  50. package/dist/registry.d.ts +26 -0
  51. package/dist/registry.d.ts.map +1 -0
  52. package/dist/registry.js +34 -0
  53. package/dist/registry.js.map +1 -0
  54. package/dist/util/fs.d.ts +13 -0
  55. package/dist/util/fs.d.ts.map +1 -0
  56. package/dist/util/fs.js +32 -0
  57. package/dist/util/fs.js.map +1 -0
  58. package/dist/util/log.d.ts +8 -0
  59. package/dist/util/log.d.ts.map +1 -0
  60. package/dist/util/log.js +9 -0
  61. package/dist/util/log.js.map +1 -0
  62. package/dist/util/markers.d.ts +20 -0
  63. package/dist/util/markers.d.ts.map +1 -0
  64. package/dist/util/markers.js +51 -0
  65. package/dist/util/markers.js.map +1 -0
  66. package/dist/util/run.d.ts +8 -0
  67. package/dist/util/run.d.ts.map +1 -0
  68. package/dist/util/run.js +27 -0
  69. package/dist/util/run.js.map +1 -0
  70. package/package.json +31 -0
  71. package/template/.commitlintrc.json +3 -0
  72. package/template/.prettierrc +17 -0
  73. package/template/api/core/ApiClient.ts +99 -0
  74. package/template/api/core/types.ts +29 -0
  75. package/template/api/index.ts +4 -0
  76. package/template/api/models/pet.ts +24 -0
  77. package/template/api/services/PetService.ts +67 -0
  78. package/template/config/envUtils.ts +70 -0
  79. package/template/config/index.ts +2 -0
  80. package/template/config/loadEnv.ts +110 -0
  81. package/template/env/environments.example.json +20 -0
  82. package/template/eslint.config.js +74 -0
  83. package/template/fixtures/api.ts +46 -0
  84. package/template/fixtures/auth.ts +150 -0
  85. package/template/fixtures/index.ts +38 -0
  86. package/template/fixtures/ui.ts +112 -0
  87. package/template/pages/BasePage.ts +225 -0
  88. package/template/pages/LoginPage.ts +50 -0
  89. package/template/pages/index.ts +2 -0
  90. package/template/playwright.config.ts +71 -0
  91. package/template/templates/gitignore +19 -0
  92. package/template/testData/users.example.json +17 -0
  93. package/template/tests/api/pet.api.ts +60 -0
  94. package/template/tests/example/authSession.spec.ts +34 -0
  95. package/template/tests/example/login.spec.ts +50 -0
  96. package/template/tsconfig.json +33 -0
  97. package/template/utils/apiUtils.ts +224 -0
  98. package/template/utils/dateUtils.ts +158 -0
  99. package/template/utils/index.ts +15 -0
  100. package/template/utils/stringUtils.ts +192 -0
  101. package/template/utils/uiUtils.ts +236 -0
  102. package/template/utils/validationUtils.ts +190 -0
  103. package/template/utils/waitUtils.ts +207 -0
@@ -0,0 +1,27 @@
1
+ import { spawn } from 'node:child_process';
2
+ /** Strip inherited `npm_*` lifecycle env so a nested `npm install` uses a clean config. */
3
+ function cleanEnv() {
4
+ const env = { ...process.env };
5
+ for (const key of Object.keys(env)) {
6
+ if (key.startsWith('npm_')) {
7
+ delete env[key];
8
+ }
9
+ }
10
+ return env;
11
+ }
12
+ /** Run a command to completion; rejects on non-zero exit or spawn error. */
13
+ export function run(cmd, args, opts = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(cmd, args, {
16
+ cwd: opts.cwd,
17
+ env: cleanEnv(),
18
+ stdio: opts.silent ? 'ignore' : 'inherit',
19
+ shell: false,
20
+ });
21
+ child.on('error', reject);
22
+ child.on('close', code => code === 0
23
+ ? resolve()
24
+ : reject(new Error(`\`${cmd} ${args.join(' ')}\` exited with code ${code}`)));
25
+ });
26
+ }
27
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/util/run.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C,2FAA2F;AAC3F,SAAS,QAAQ;IACf,MAAM,GAAG,GAAsB,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;IAClD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACnC,IAAI,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YAC3B,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAQD,4EAA4E;AAC5E,MAAM,UAAU,GAAG,CAAC,GAAW,EAAE,IAAc,EAAE,OAAmB,EAAE;IACpE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE;YAC7B,GAAG,EAAE,IAAI,CAAC,GAAG;YACb,GAAG,EAAE,QAAQ,EAAE;YACf,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS;YACzC,KAAK,EAAE,KAAK;SACb,CAAC,CAAC;QACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC1B,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,CACvB,IAAI,KAAK,CAAC;YACR,CAAC,CAAC,OAAO,EAAE;YACX,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,KAAK,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,uBAAuB,IAAI,EAAE,CAAC,CAAC,CAC/E,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@pwtap/create",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a Playwright Test Automation Platform project — UI + API core with opt-in plugins (npm init @pwtap)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/caslanqa/playwright-ai-distro.git",
10
+ "directory": "packages/create"
11
+ },
12
+ "engines": {
13
+ "node": ">=20.19"
14
+ },
15
+ "bin": {
16
+ "create-pwtap": "./dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "template",
21
+ "core-manifest.json"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -b",
28
+ "bundle:template": "rm -rf template core-manifest.json && cp -R ../core-template/files template && cp ../core-template/manifest.json core-manifest.json",
29
+ "prepack": "npm run build && npm run bundle:template"
30
+ }
31
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": ["@commitlint/config-conventional"]
3
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "semi": true,
3
+ "trailingComma": "all",
4
+ "singleQuote": true,
5
+ "printWidth": 100,
6
+ "tabWidth": 2,
7
+ "useTabs": false,
8
+ "arrowParens": "avoid",
9
+ "bracketSpacing": true,
10
+ "endOfLine": "auto",
11
+ "proseWrap": "preserve",
12
+ "quoteProps": "as-needed",
13
+ "bracketSameLine": false,
14
+ "jsxSingleQuote": false,
15
+ "embeddedLanguageFormatting": "auto",
16
+ "plugins": ["prettier-plugin-organize-imports"]
17
+ }
@@ -0,0 +1,99 @@
1
+ import type { APIRequestContext, APIResponse } from '@playwright/test';
2
+
3
+ import type { ApiResponse, HttpMethod, RequestOptions } from './types';
4
+
5
+ /**
6
+ * Layer 1 — the base HTTP client. A thin, typed wrapper over Playwright's APIRequestContext that
7
+ * exposes get/post/put/patch/delete, resolves paths against an optional basePath, and normalizes
8
+ * every response into an {@link ApiResponse} (status + parsed body). Service classes (layer 2) build
9
+ * on this; tests (layer 3) never touch the raw request context.
10
+ *
11
+ * @example
12
+ * const client = new ApiClient(request); // `request` is the Playwright fixture
13
+ * const res = await client.get<Pet[]>('/pet/findByStatus', { params: { status: 'available' } });
14
+ * expect(res.ok).toBeTruthy();
15
+ */
16
+ export class ApiClient {
17
+ private readonly request: APIRequestContext;
18
+ private readonly basePath: string;
19
+ private readonly defaultHeaders: Record<string, string>;
20
+
21
+ /**
22
+ * @param request Playwright's APIRequestContext (the built-in `request` fixture).
23
+ * @param basePath Absolute API root prepended to every URL (e.g. 'https://host/api/v3').
24
+ * URLs are built by concatenation, so service paths keep their leading slash.
25
+ * @param defaultHeaders Headers sent on every request; a per-request `headers` value overrides
26
+ * them. Defaults to `Accept: application/json` — extend for auth tokens, etc.
27
+ */
28
+ constructor(
29
+ request: APIRequestContext,
30
+ basePath = '',
31
+ defaultHeaders: Record<string, string> = { Accept: 'application/json' },
32
+ ) {
33
+ this.request = request;
34
+ this.basePath = basePath;
35
+ this.defaultHeaders = defaultHeaders;
36
+ }
37
+
38
+ async get<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>> {
39
+ return this.send<T>('GET', url, options);
40
+ }
41
+
42
+ async post<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>> {
43
+ return this.send<T>('POST', url, options);
44
+ }
45
+
46
+ async put<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>> {
47
+ return this.send<T>('PUT', url, options);
48
+ }
49
+
50
+ async patch<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>> {
51
+ return this.send<T>('PATCH', url, options);
52
+ }
53
+
54
+ async delete<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>> {
55
+ return this.send<T>('DELETE', url, options);
56
+ }
57
+
58
+ /** Single code path for every verb: issue the request and normalize the response. */
59
+ private async send<T>(
60
+ method: HttpMethod,
61
+ url: string,
62
+ options: RequestOptions = {},
63
+ ): Promise<ApiResponse<T>> {
64
+ const response = await this.request.fetch(`${this.basePath}${url}`, {
65
+ method,
66
+ params: options.params,
67
+ headers: { ...this.defaultHeaders, ...options.headers },
68
+ data: options.data,
69
+ });
70
+
71
+ return {
72
+ status: response.status(),
73
+ ok: response.ok(),
74
+ headers: response.headers(),
75
+ data: await this.parseBody<T>(response),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Parse the body without throwing: JSON when the response advertises JSON, otherwise raw text;
81
+ * `undefined` for an empty body (e.g. a 204 or a bare-text DELETE). Reading via text() first
82
+ * avoids APIResponse.json() throwing on non-JSON payloads (Petstore's DELETE returns "Pet deleted").
83
+ */
84
+ private async parseBody<T>(response: APIResponse): Promise<T> {
85
+ const body = await response.text();
86
+ if (body.length === 0) {
87
+ return undefined as T;
88
+ }
89
+ const contentType = response.headers()['content-type'] ?? '';
90
+ if (contentType.includes('application/json')) {
91
+ try {
92
+ return JSON.parse(body) as T;
93
+ } catch {
94
+ return body as unknown as T;
95
+ }
96
+ }
97
+ return body as unknown as T;
98
+ }
99
+ }
@@ -0,0 +1,29 @@
1
+ import type { APIRequestContext } from '@playwright/test';
2
+
3
+ /** HTTP verbs supported by the base client. */
4
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
5
+
6
+ /** The options accepted by Playwright's APIRequestContext.fetch — reused so our types never drift. */
7
+ type FetchOptions = NonNullable<Parameters<APIRequestContext['fetch']>[1]>;
8
+
9
+ /** Per-request options passed to the base client methods. */
10
+ export interface RequestOptions {
11
+ /** Query-string parameters appended to the URL. */
12
+ params?: FetchOptions['params'];
13
+ /** Extra headers merged over the client/project defaults. */
14
+ headers?: FetchOptions['headers'];
15
+ /** Request body. A plain object is serialized as JSON (Content-Type set automatically). */
16
+ data?: FetchOptions['data'];
17
+ }
18
+
19
+ /** A parsed HTTP response: status + typed body, decoupled from Playwright's APIResponse. */
20
+ export interface ApiResponse<T> {
21
+ /** HTTP status code (e.g. 200, 404). */
22
+ status: number;
23
+ /** True for 2xx responses. */
24
+ ok: boolean;
25
+ /** Parsed body: JSON when the response is JSON, else the raw text; undefined when the body is empty. */
26
+ data: T;
27
+ /** Response headers (lower-cased keys). */
28
+ headers: Record<string, string>;
29
+ }
@@ -0,0 +1,4 @@
1
+ export { ApiClient } from './core/ApiClient';
2
+ export type { ApiResponse, HttpMethod, RequestOptions } from './core/types';
3
+ export type { Category, Pet, PetStatus, Tag } from './models/pet';
4
+ export { PetService } from './services/PetService';
@@ -0,0 +1,24 @@
1
+ /** Petstore domain models — the subset of the OpenAPI v3 schema these examples exercise. */
2
+
3
+ /** A pet's lifecycle status in the store. */
4
+ export type PetStatus = 'available' | 'pending' | 'sold';
5
+
6
+ export interface Category {
7
+ id?: number;
8
+ name?: string;
9
+ }
10
+
11
+ export interface Tag {
12
+ id?: number;
13
+ name?: string;
14
+ }
15
+
16
+ export interface Pet {
17
+ /** Server-assigned id; must stay within JS safe-integer range when set by a test. */
18
+ id?: number;
19
+ name: string;
20
+ category?: Category;
21
+ photoUrls: string[];
22
+ tags?: Tag[];
23
+ status?: PetStatus;
24
+ }
@@ -0,0 +1,67 @@
1
+ import type { ApiClient } from '../core/ApiClient';
2
+ import type { Pet, PetStatus } from '../models/pet';
3
+
4
+ /**
5
+ * Layer 2 — the business/service layer for Petstore's /pet resource. It calls the base
6
+ * {@link ApiClient} (layer 1) and turns raw HTTP into business operations: fetch by status, CRUD,
7
+ * and — the point of this layer — results the API does not offer directly, e.g. "available pets in
8
+ * a given category", by reading a list and filtering on a property HERE rather than in the test.
9
+ *
10
+ * @example
11
+ * const pets = await petService.findAvailableByCategory('Dogs');
12
+ */
13
+ export class PetService {
14
+ private readonly client: ApiClient;
15
+
16
+ constructor(client: ApiClient) {
17
+ this.client = client;
18
+ }
19
+
20
+ /** GET /pet/findByStatus — every pet with the given status. */
21
+ async findByStatus(status: PetStatus): Promise<Pet[]> {
22
+ const res = await this.client.get<Pet[]>('/pet/findByStatus', { params: { status } });
23
+ if (!res.ok) {
24
+ throw new Error(`[PetService] findByStatus(${status}) failed: HTTP ${res.status}`);
25
+ }
26
+ return res.data ?? [];
27
+ }
28
+
29
+ /** Convenience wrapper: all currently available pets. */
30
+ async findAvailable(): Promise<Pet[]> {
31
+ return this.findByStatus('available');
32
+ }
33
+
34
+ /**
35
+ * Business rule (read-then-filter): fetch available pets, then keep only those carrying the given
36
+ * category name. The endpoint has no category filter, so the logic lives in this layer — the test
37
+ * just asks for "available Dogs" and asserts on the result.
38
+ */
39
+ async findAvailableByCategory(categoryName: string): Promise<Pet[]> {
40
+ const available = await this.findAvailable();
41
+ return available.filter(pet => pet.category?.name === categoryName);
42
+ }
43
+
44
+ /** GET /pet/{id} — a single pet. */
45
+ async getById(id: number): Promise<Pet> {
46
+ const res = await this.client.get<Pet>(`/pet/${id}`);
47
+ if (!res.ok) {
48
+ throw new Error(`[PetService] getById(${id}) failed: HTTP ${res.status}`);
49
+ }
50
+ return res.data;
51
+ }
52
+
53
+ /** POST /pet — create a pet, returning the created record. */
54
+ async create(pet: Pet): Promise<Pet> {
55
+ const res = await this.client.post<Pet>('/pet', { data: pet });
56
+ if (!res.ok) {
57
+ throw new Error(`[PetService] create('${pet.name}') failed: HTTP ${res.status}`);
58
+ }
59
+ return res.data;
60
+ }
61
+
62
+ /** DELETE /pet/{id} — remove a pet; returns the HTTP status so tests can assert on it. */
63
+ async deleteById(id: number): Promise<number> {
64
+ const res = await this.client.delete(`/pet/${id}`);
65
+ return res.status;
66
+ }
67
+ }
@@ -0,0 +1,70 @@
1
+ import { loadEnv } from './loadEnv';
2
+
3
+ // Load the selected environment (TEST_ENV, default from environments.json)
4
+ // before any getEnv call. Centralized so every consumer switches envs via one variable.
5
+ loadEnv();
6
+
7
+ /**
8
+ * Get environment variable value with optional default
9
+ * Removes surrounding quotes if present
10
+ * @param key - The environment variable key
11
+ * @param defaultValue - Optional default value if key is not found
12
+ * @returns The environment variable value or default value
13
+ * @throws Error if key is not found and no default value is provided
14
+ */
15
+ export const getEnv = (key: string, defaultValue?: string): string => {
16
+ const value = process.env[key];
17
+
18
+ if (value === undefined) {
19
+ if (defaultValue !== undefined) {
20
+ return defaultValue;
21
+ }
22
+ throw new Error(`Environment variable "${key}" is not defined`);
23
+ }
24
+
25
+ // Remove surrounding quotes (both single and double) if present
26
+ // This handles cases where .env values are quoted
27
+ return value.replace(/^["']|["']$/g, '');
28
+ };
29
+
30
+ /**
31
+ * Get environment variable as a number
32
+ * @param key - The environment variable key
33
+ * @param defaultValue - Optional default value
34
+ * @returns The environment variable value as a number
35
+ */
36
+ export const getEnvNumber = (key: string, defaultValue?: number): number => {
37
+ const value = getEnv(key, defaultValue?.toString());
38
+ const num = Number(value);
39
+
40
+ if (isNaN(num)) {
41
+ throw new Error(`Environment variable "${key}" must be a valid number, got "${value}"`);
42
+ }
43
+
44
+ return num;
45
+ };
46
+
47
+ /**
48
+ * Get environment variable as a boolean
49
+ * @param key - The environment variable key
50
+ * @param defaultValue - Optional default value
51
+ * @returns The environment variable value as a boolean
52
+ */
53
+ export const getEnvBoolean = (key: string, defaultValue?: boolean): boolean => {
54
+ const value = getEnv(key, defaultValue?.toString());
55
+ return value.toLowerCase() === 'true' || value === '1' || value === 'yes';
56
+ };
57
+
58
+ /**
59
+ * Check if environment variable exists
60
+ * @param key - The environment variable key
61
+ * @returns true if the environment variable exists, false otherwise
62
+ */
63
+ export const hasEnv = (key: string): boolean => process.env[key] !== undefined;
64
+
65
+ /**
66
+ * Get all environment variables as an object
67
+ * @returns An object containing all environment variables
68
+ */
69
+ export const getAllEnv = (): Record<string, string> =>
70
+ ({ ...process.env }) as Record<string, string>;
@@ -0,0 +1,2 @@
1
+ export { getAllEnv, getEnv, getEnvBoolean, getEnvNumber, hasEnv } from './envUtils';
2
+ export { loadEnv } from './loadEnv';
@@ -0,0 +1,110 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ // Single source of truth for environment loading. Every consumer (envUtils, the fixtures,
5
+ // playwright.config) goes through loadEnv() so switching environments is a single variable change.
6
+ //
7
+ // All config lives in env/environments.json with two blocks:
8
+ // {
9
+ // "common": { ...shared string keys, may contain ${TEST_ENV.X} tokens... },
10
+ // "environments": { "<env>": { "BASE_URL": "...", ...other per-env scalars... } }
11
+ // }
12
+ // The environment is selected via TEST_ENV (falls back to common.DEFAULT_TEST_ENV). loadEnv flattens
13
+ // every string scalar (from `common` and the selected environment block) into process.env, e.g.
14
+ // common.DEFAULT_TEST_ENV and environments.dev.BASE_URL both become process.env keys.
15
+ //
16
+ // Login credentials do NOT live here — named sessions are in testData/users.json and consumed
17
+ // directly by the auth fixtures (fixtures/auth.ts) when a session is first used.
18
+
19
+ const ENV_FILE = 'environments.json';
20
+
21
+ // Matches ${TEST_ENV.SOME_KEY} placeholders inside common string values; the key is resolved
22
+ // against the selected environment block (e.g. ${TEST_ENV.BASE_URL}).
23
+ const TEST_ENV_TOKEN = /\$\{TEST_ENV\.([A-Za-z0-9_]+)\}/g;
24
+
25
+ let loaded = false;
26
+ let resolvedEnv = '';
27
+
28
+ interface EnvConfig {
29
+ common?: Record<string, unknown>;
30
+ environments?: Record<string, Record<string, unknown>>;
31
+ }
32
+
33
+ /**
34
+ * Convert a config key to its flat env-var form: camelCase becomes SCREAMING_SNAKE_CASE
35
+ * (e.g. "supabaseUrl" -> "SUPABASE_URL"); an already upper/snake key is left unchanged.
36
+ */
37
+ function toEnvKey(key: string): string {
38
+ return key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
39
+ }
40
+
41
+ /**
42
+ * Set a process.env key unless it is a documentation key (leading "_"/"$") or was already defined
43
+ * explicitly (e.g. an exported CI secret), so an explicit value always wins over the file.
44
+ */
45
+ function setEnv(key: string, value: string): void {
46
+ if (key.startsWith('_') || key.startsWith('$')) {
47
+ return;
48
+ }
49
+ if (process.env[key] === undefined) {
50
+ process.env[key] = value;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Replace ${TEST_ENV.X} tokens in a string with the matching scalar from the selected environment
56
+ * block. An unknown/missing key resolves to an empty string so a malformed template degrades
57
+ * visibly rather than crashing the run.
58
+ */
59
+ function resolveTokens(value: string, envBlock: Record<string, unknown>): string {
60
+ return value.replace(TEST_ENV_TOKEN, (_match, key: string) => {
61
+ const resolved = envBlock[key];
62
+ return typeof resolved === 'string' ? resolved : '';
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Load env/environments.json, flattening string scalars into process.env keys. Idempotent — safe to
68
+ * call multiple times.
69
+ *
70
+ * Environment selection: TEST_ENV env var > common.DEFAULT_TEST_ENV fallback.
71
+ *
72
+ * @returns The name of the selected environment for logging/debugging.
73
+ */
74
+ export function loadEnv(): string {
75
+ if (loaded) {
76
+ return resolvedEnv;
77
+ }
78
+ loaded = true;
79
+
80
+ const file = path.join(process.cwd(), 'env', ENV_FILE);
81
+ if (!fs.existsSync(file)) {
82
+ console.warn(`[loadEnv] ${file} not found — skipping.`);
83
+ return '';
84
+ }
85
+
86
+ const config = JSON.parse(fs.readFileSync(file, 'utf8')) as EnvConfig;
87
+ const common = config.common ?? {};
88
+
89
+ // Determine environment: explicit TEST_ENV wins, else fallback from common.
90
+ resolvedEnv = process.env.TEST_ENV ?? (common.DEFAULT_TEST_ENV as string) ?? '';
91
+
92
+ const envBlock = config.environments?.[resolvedEnv] ?? {};
93
+
94
+ // 1. Flatten common string scalars (resolve ${TEST_ENV.X} tokens against the env block).
95
+ for (const [key, val] of Object.entries(common)) {
96
+ if (typeof val === 'string') {
97
+ setEnv(toEnvKey(key), resolveTokens(val, envBlock));
98
+ }
99
+ }
100
+
101
+ // 2. Flatten the selected environment block's string scalars (BASE_URL, etc.).
102
+ for (const [key, val] of Object.entries(envBlock)) {
103
+ if (typeof val === 'string') {
104
+ setEnv(toEnvKey(key), val);
105
+ }
106
+ }
107
+
108
+ console.info(`[loadEnv] Loaded environment: ${resolvedEnv}`);
109
+ return resolvedEnv;
110
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "_note": "Per-environment test config. Select with TEST_ENV (default = common.DEFAULT_TEST_ENV). 'common' is merged into every env (with ${TEST_ENV.X} resolved from the selected env block). 'environments' holds per-env scalars: BASE_URL (UI, the Playwright baseURL) and API_BASE_URL (the api project's baseURL, kept separate so the two never collide). Every string scalar is flattened to a process.env key by loadEnv. Login sessions live in testData/users.json (not here). Plugins add their own keys under 'common' when installed.",
3
+ "common": {
4
+ "DEFAULT_TEST_ENV": "dev"
5
+ },
6
+ "environments": {
7
+ "dev": {
8
+ "BASE_URL": "https://www.saucedemo.com/",
9
+ "API_BASE_URL": "https://petstore3.swagger.io/api/v3"
10
+ },
11
+ "staging": {
12
+ "BASE_URL": "https://www.saucedemo.com/",
13
+ "API_BASE_URL": "https://petstore3.swagger.io/api/v3"
14
+ },
15
+ "production": {
16
+ "BASE_URL": "https://www.saucedemo.com/",
17
+ "API_BASE_URL": "https://petstore3.swagger.io/api/v3"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,74 @@
1
+ import tseslint from '@typescript-eslint/eslint-plugin';
2
+ import tsparser from '@typescript-eslint/parser';
3
+ import prettierConfig from 'eslint-config-prettier';
4
+ import playwright from 'eslint-plugin-playwright';
5
+ import prettier from 'eslint-plugin-prettier';
6
+
7
+ export default [
8
+ {
9
+ ignores: [
10
+ 'node_modules/**',
11
+ 'dist/**',
12
+ 'playwright-report/**',
13
+ 'test-results/**',
14
+ 'allure-results/**',
15
+ 'allure-report/**',
16
+ '*.log',
17
+ ],
18
+ },
19
+ {
20
+ files: ['**/*.ts'],
21
+ languageOptions: {
22
+ parser: tsparser,
23
+ parserOptions: {
24
+ ecmaVersion: 2022,
25
+ sourceType: 'module',
26
+ projectService: true,
27
+ },
28
+ globals: {
29
+ console: 'readonly',
30
+ process: 'readonly',
31
+ Buffer: 'readonly',
32
+ __dirname: 'readonly',
33
+ __filename: 'readonly',
34
+ setTimeout: 'readonly',
35
+ clearTimeout: 'readonly',
36
+ setInterval: 'readonly',
37
+ clearInterval: 'readonly',
38
+ },
39
+ },
40
+ plugins: {
41
+ '@typescript-eslint': tseslint,
42
+ playwright: playwright,
43
+ prettier: prettier,
44
+ },
45
+ rules: {
46
+ ...tseslint.configs.recommended.rules,
47
+ ...prettierConfig.rules,
48
+ '@typescript-eslint/no-explicit-any': 'warn',
49
+ '@typescript-eslint/explicit-function-return-type': 'off',
50
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
51
+ '@typescript-eslint/no-namespace': 'off',
52
+ '@typescript-eslint/no-unused-vars': [
53
+ 'warn',
54
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
55
+ ],
56
+ 'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
57
+ 'prefer-const': 'warn',
58
+ 'no-var': 'error',
59
+ 'prettier/prettier': ['error', { endOfLine: 'auto' }],
60
+ },
61
+ },
62
+ {
63
+ files: ['**/*.spec.ts', '**/*.test.ts', 'tests/**/*.ts'],
64
+ plugins: { playwright: playwright },
65
+ rules: {
66
+ ...playwright.configs['flat/recommended'].rules,
67
+ '@typescript-eslint/no-explicit-any': 'off',
68
+ // `test.as(session)('title', fn)` is a valid test block the plugin can't statically detect.
69
+ 'playwright/no-standalone-expect': 'off',
70
+ 'playwright/no-conditional-in-test': 'off',
71
+ 'playwright/no-networkidle': 'off',
72
+ },
73
+ },
74
+ ];
@@ -0,0 +1,46 @@
1
+ import { test as base, expect } from '@playwright/test';
2
+
3
+ import { ApiClient } from '@api/core/ApiClient';
4
+ import { PetService } from '@api/services/PetService';
5
+ import { loadEnv } from '@config/loadEnv';
6
+
7
+ // Load the selected environment (API_BASE_URL → the api project's baseURL) before any test runs.
8
+ loadEnv();
9
+
10
+ /** API-layer fixtures: the base client (layer 1) and the services (layer 2) built on top of it. */
11
+ export interface ApiFixtures {
12
+ /** Layer 1 — base HTTP client bound to this test's APIRequestContext (baseURL = API_BASE_URL). */
13
+ apiClient: ApiClient;
14
+ /** Layer 2 — Petstore /pet business service. */
15
+ petService: PetService;
16
+ }
17
+
18
+ /**
19
+ * Test object for API tests. It extends the plain Playwright base (no browser/auth — API tests need
20
+ * neither) with an `apiClient` wrapping the built-in `request` fixture, so the client inherits the
21
+ * api project's baseURL/headers, plus the service objects. Import this in tests/api/*.api.ts.
22
+ *
23
+ * @example
24
+ * import { test, expect } from '@fixtures/apiFixtures';
25
+ * test('available pets', async ({ petService }) => {
26
+ * const pets = await petService.findAvailable();
27
+ * expect(pets.length).toBeGreaterThan(0);
28
+ * });
29
+ */
30
+ export const test = base.extend<ApiFixtures>({
31
+ apiClient: async ({ request }, use) => {
32
+ // API_BASE_URL (env/environments.json) includes a path (/api/v3), so we pass it as the client's
33
+ // basePath and build URLs by absolute concatenation — a leading-slash path against a context
34
+ // baseURL would drop that path (WHATWG URL join) and hit the wrong host root.
35
+ const baseUrl = process.env.API_BASE_URL;
36
+ if (baseUrl === undefined || baseUrl.length === 0) {
37
+ throw new Error('[api] API_BASE_URL is not set (env/environments.json)');
38
+ }
39
+ await use(new ApiClient(request, baseUrl));
40
+ },
41
+ petService: async ({ apiClient }, use) => {
42
+ await use(new PetService(apiClient));
43
+ },
44
+ });
45
+
46
+ export { expect };