@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,150 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import type { Browser, Page } from '@playwright/test';
5
+
6
+ import { LoginPage } from '@pages/LoginPage';
7
+
8
+ /** Directory where per-session storage-state files are cached. */
9
+ export const AUTH_DIR = '.auth';
10
+
11
+ /** A single login session's credentials, as declared in testData/users.json. */
12
+ export interface UserCredentials {
13
+ username: string;
14
+ password: string;
15
+ }
16
+
17
+ /** Resolve a session key (e.g. `'admin'`) to its storage-state file path (`.auth/admin.json`). */
18
+ export function authState(key: string): string {
19
+ return path.join(AUTH_DIR, `${key}.json`);
20
+ }
21
+
22
+ /**
23
+ * Read the named login sessions from testData/users.json (gitignored). Returns `{}` when the file
24
+ * is absent, so a fresh scaffold with no users configured simply has no sessions.
25
+ */
26
+ export function readUsers(): Record<string, UserCredentials> {
27
+ const file = path.join(process.cwd(), 'testData', 'users.json');
28
+ if (!fs.existsSync(file)) {
29
+ return {};
30
+ }
31
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as {
32
+ users?: Record<string, UserCredentials>;
33
+ };
34
+
35
+ return parsed.users ?? {};
36
+ }
37
+
38
+ /** A storage-state file counts as valid once it actually carries cookies or localStorage. */
39
+ function isValidState(file: string): boolean {
40
+ if (!fs.existsSync(file)) {
41
+ return false;
42
+ }
43
+ try {
44
+ const state = JSON.parse(fs.readFileSync(file, 'utf8')) as {
45
+ cookies?: unknown[];
46
+ origins?: Array<{ localStorage?: unknown[] }>;
47
+ };
48
+ return (state.cookies?.length ?? 0) > 0 || (state.origins?.[0]?.localStorage?.length ?? 0) > 0;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
55
+
56
+ /**
57
+ * Run `fn` under a cross-process lock keyed by `file` (atomic mkdir). Only one worker performs the
58
+ * login; the others wait for the resulting file to appear and reuse it — so a session is created
59
+ * exactly once even under parallel workers.
60
+ */
61
+ async function withLock(file: string, fn: () => Promise<void>): Promise<void> {
62
+ if (!fs.existsSync(AUTH_DIR)) {
63
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
64
+ }
65
+ const lockDir = `${file}.lock`;
66
+
67
+ let acquired = false;
68
+ try {
69
+ fs.mkdirSync(lockDir);
70
+ acquired = true;
71
+ } catch {
72
+ acquired = false;
73
+ }
74
+
75
+ if (acquired) {
76
+ try {
77
+ await fn();
78
+ } finally {
79
+ try {
80
+ fs.rmdirSync(lockDir);
81
+ } catch {
82
+ // already removed
83
+ }
84
+ }
85
+ return;
86
+ }
87
+
88
+ // Another worker holds the lock — wait for it to produce the file.
89
+ const maxWaitMs = 60_000;
90
+ const intervalMs = 300;
91
+ for (let waited = 0; waited < maxWaitMs; waited += intervalMs) {
92
+ if (!fs.existsSync(lockDir) && isValidState(file)) {
93
+ return;
94
+ }
95
+ await sleep(intervalMs);
96
+ }
97
+ throw new Error(`[auth] timed out waiting for another worker to create session file: ${file}`);
98
+ }
99
+
100
+ /** The minimal contract the auth flow needs from a login page object. */
101
+ export interface SessionLogin {
102
+ /** Navigate to the login page, authenticate, and wait until signed in. */
103
+ signIn(username: string, password: string): Promise<void>;
104
+ }
105
+
106
+ /** Maps a session key to the login page object that authenticates it. */
107
+ export type CreateLoginPage = (page: Page, key: string) => SessionLogin;
108
+
109
+ /**
110
+ * Default factory: every session logs in through the one LoginPage. Override the `createLoginPage`
111
+ * fixture only if different sessions need different login page objects.
112
+ */
113
+ export const defaultCreateLoginPage: CreateLoginPage = page => new LoginPage(page);
114
+
115
+ /**
116
+ * Ensure `.auth/<key>.json` exists: reuse it if already cached, otherwise log in once (worker-safe)
117
+ * and save it. Credentials come from testData/users.json; the login page object is built by
118
+ * `createLoginPage` (the fixture-provided factory). Called lazily by the `session` fixture, and
119
+ * directly for multi-role tests that open their own contexts.
120
+ */
121
+ export async function ensureSession(
122
+ browser: Browser,
123
+ key: string,
124
+ createLoginPage: CreateLoginPage = defaultCreateLoginPage,
125
+ ): Promise<void> {
126
+ const file = authState(key);
127
+ if (isValidState(file)) {
128
+ return;
129
+ }
130
+
131
+ await withLock(file, async () => {
132
+ if (isValidState(file)) {
133
+ return; // produced by another worker while we waited for the lock
134
+ }
135
+ const creds = readUsers()[key];
136
+ if (creds === undefined) {
137
+ throw new Error(
138
+ `[auth] no credentials for session '${key}' — add it to testData/users.json.`,
139
+ );
140
+ }
141
+ const context = await browser.newContext({ baseURL: process.env.BASE_URL });
142
+ try {
143
+ const page = await context.newPage();
144
+ await createLoginPage(page, key).signIn(creds.username, creds.password);
145
+ await context.storageState({ path: file });
146
+ } finally {
147
+ await context.close();
148
+ }
149
+ });
150
+ }
@@ -0,0 +1,38 @@
1
+ import { mergeExpects, mergeTests } from '@playwright/test';
2
+
3
+ import { test as apiTest } from './api';
4
+ import { expect as uiExpect, test as uiTest, withSessionAuth } from './ui';
5
+
6
+ /**
7
+ * The single, editable test object for this project — core UI + API fixtures, plus any installed
8
+ * plugin fixtures merged in below. Import `test` and `expect` from `@fixtures` everywhere. Auth works
9
+ * at suite level (`test.use({ session })`) or per test (`test.as('session')('title', fn)`).
10
+ *
11
+ * The regions between the `pwtap:` markers are maintained by `create-pwtap add|remove`. Edit outside
12
+ * the markers freely; the tool only rewrites between them.
13
+ *
14
+ * @example
15
+ * import { test, expect } from '@fixtures';
16
+ * test.as('adminUser')('reaches the app', async ({ page }) => {
17
+ * await page.goto('/');
18
+ * await expect(page).toHaveURL(/\//);
19
+ * });
20
+ */
21
+
22
+ // pwtap:plugins:imports
23
+ // pwtap:plugins:imports:end
24
+
25
+ export const test = withSessionAuth(
26
+ mergeTests(
27
+ uiTest,
28
+ apiTest,
29
+ // pwtap:plugins:tests
30
+ // pwtap:plugins:tests:end
31
+ ),
32
+ );
33
+
34
+ export const expect = mergeExpects(
35
+ uiExpect,
36
+ // pwtap:plugins:expects
37
+ // pwtap:plugins:expects:end
38
+ );
@@ -0,0 +1,112 @@
1
+ import { test as base, expect, type TestInfo, type TestType } from '@playwright/test';
2
+
3
+ import { loadEnv } from '@config/loadEnv';
4
+
5
+ import { authState, defaultCreateLoginPage, ensureSession, type CreateLoginPage } from './auth';
6
+
7
+ // Load the selected environment (TEST_ENV → BASE_URL → baseURL) before any test runs.
8
+ loadEnv();
9
+
10
+ /** Annotation type carrying a per-test session key, set by `test.as(session)` and read by storageState. */
11
+ const SESSION_ANNOTATION = 'pwtap:session';
12
+
13
+ /** Options this test object adds on top of the built-in Playwright fixtures. */
14
+ export interface AuthOptions {
15
+ /**
16
+ * Suite-level session key: `test.use({ session })` at file/describe scope authenticates every test
17
+ * in that scope. On first use its `.auth/<session>.json` is created by logging in (once,
18
+ * worker-safe) and reused later. Leave unset for an unauthenticated context. For per-test auth use
19
+ * `test.as(session)` instead.
20
+ */
21
+ session: string | undefined;
22
+ }
23
+
24
+ /** Non-option fixtures this test object adds. */
25
+ interface AuthFixtures {
26
+ /**
27
+ * Factory mapping a session key to its login page object. Init'd here so the auth flow logs in
28
+ * through a page object (never constructing one inline). Override to wire your app's page objects.
29
+ */
30
+ createLoginPage: CreateLoginPage;
31
+ }
32
+
33
+ /**
34
+ * Core UI test object. Adds a `session` option for suite-level auth and overrides `storageState` so
35
+ * the authenticated state is applied lazily and cached. Per-test auth is layered on separately by
36
+ * `withSessionAuth` (the `test.as(session)` API), which sets an annotation this fixture reads.
37
+ */
38
+ export const test = base.extend<AuthOptions & AuthFixtures>({
39
+ session: [undefined, { option: true }],
40
+
41
+ // The login page-object factory, provided as a fixture so the auth flow doesn't `new` one inline.
42
+ createLoginPage: async ({}, use) => {
43
+ await use(defaultCreateLoginPage);
44
+ },
45
+
46
+ // Effective session = per-test annotation (test.as) > suite-level option (test.use) > none.
47
+ // When set, ensure the cached state exists (log in on a miss) and apply it as storageState.
48
+ storageState: async ({ browser, session, storageState, createLoginPage }, use, testInfo) => {
49
+ const perTest = testInfo.annotations.find(a => a.type === SESSION_ANNOTATION)?.description;
50
+ const effective = perTest ?? session;
51
+ if (effective === undefined) {
52
+ await use(storageState);
53
+ return;
54
+ }
55
+ await ensureSession(browser, effective, createLoginPage);
56
+ await use(authState(effective));
57
+ },
58
+ });
59
+
60
+ export { expect };
61
+
62
+ /** A test object of any fixture shape — the constraint for the session-auth helpers below. */
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ type AnyTest = TestType<any, any>;
65
+
66
+ /** Fixtures + worker args of a test type, as passed to its test body. */
67
+ type TestArgsOf<T> = T extends TestType<infer A, infer W> ? A & W : never;
68
+ type SessionTestBody<T> = (args: TestArgsOf<T>, testInfo: TestInfo) => void | Promise<void>;
69
+
70
+ /** A session-bound test declarator: callable like `test`, with the standard declaration modifiers. */
71
+ export interface SessionBoundTest<T extends AnyTest> {
72
+ (title: string, body: SessionTestBody<T>): void;
73
+ skip(title: string, body: SessionTestBody<T>): void;
74
+ only(title: string, body: SessionTestBody<T>): void;
75
+ fixme(title: string, body: SessionTestBody<T>): void;
76
+ fail(title: string, body: SessionTestBody<T>): void;
77
+ }
78
+
79
+ /**
80
+ * Attach `.as(session)` to a test object for test-level auth. `test.as('admin')('title', fn)` runs
81
+ * that single test authenticated as `admin` (its `page`/`storageState` carry the cached session), and
82
+ * `.skip`/`.only`/`.fixme`/`.fail` work the same. It sets a declaration-time annotation the
83
+ * `storageState` fixture reads, so it composes with suite-level `test.use({ session })`.
84
+ *
85
+ * @example
86
+ * export const test = withSessionAuth(mergeTests(uiTest, apiTest));
87
+ * test.as('adminUser')('sees dashboard', async ({ page }) => { await page.goto('/dashboard'); });
88
+ * test.as('customerUser').fixme('broken flow', async ({ page }) => { await page.goto('/x'); });
89
+ */
90
+ export function withSessionAuth<T extends AnyTest>(
91
+ testObj: T,
92
+ ): T & { as: (session: string) => SessionBoundTest<T> } {
93
+ const as = (session: string): SessionBoundTest<T> => {
94
+ const details = { annotation: { type: SESSION_ANNOTATION, description: session } };
95
+ // Playwright's declaration forms accept (title, details, body); cast to reach them generically.
96
+ const decl = testObj as unknown as {
97
+ (title: string, details: unknown, body: unknown): void;
98
+ skip(title: string, details: unknown, body: unknown): void;
99
+ only(title: string, details: unknown, body: unknown): void;
100
+ fixme(title: string, details: unknown, body: unknown): void;
101
+ fail(title: string, details: unknown, body: unknown): void;
102
+ };
103
+ const bound = ((title: string, body: SessionTestBody<T>) =>
104
+ decl(title, details, body)) as SessionBoundTest<T>;
105
+ bound.skip = (title, body) => decl.skip(title, details, body);
106
+ bound.only = (title, body) => decl.only(title, details, body);
107
+ bound.fixme = (title, body) => decl.fixme(title, details, body);
108
+ bound.fail = (title, body) => decl.fail(title, details, body);
109
+ return bound;
110
+ };
111
+ return Object.assign(testObj, { as });
112
+ }
@@ -0,0 +1,225 @@
1
+ import type { Locator, Page } from '@playwright/test';
2
+
3
+ /**
4
+ * Base Page Object class with common functionality.
5
+ * Extend this class for all page objects.
6
+ *
7
+ * @example
8
+ * export class LoginPage extends BasePage {
9
+ * readonly usernameInput: Locator;
10
+ * readonly passwordInput: Locator;
11
+ *
12
+ * constructor(page: Page) {
13
+ * super(page, '/login');
14
+ * this.usernameInput = page.getByPlaceholder('Email');
15
+ * this.passwordInput = page.getByPlaceholder('Password');
16
+ * }
17
+ *
18
+ * async login(username: string, password: string) {
19
+ * await this.usernameInput.fill(username);
20
+ * await this.passwordInput.fill(password);
21
+ * await this.clickButton('Sign In');
22
+ * }
23
+ * }
24
+ */
25
+ export class BasePage {
26
+ readonly page: Page;
27
+ readonly pageUrl: string;
28
+
29
+ constructor(page: Page, pageUrl: string = '/') {
30
+ this.page = page;
31
+ this.pageUrl = pageUrl;
32
+ }
33
+
34
+ /**
35
+ * Navigate to the page URL
36
+ */
37
+ async goto(options?: { waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' }): Promise<void> {
38
+ await this.page.goto(this.pageUrl, options);
39
+ }
40
+
41
+ /**
42
+ * Navigate to a custom path (relative to baseURL)
43
+ */
44
+ async navigateTo(path: string): Promise<void> {
45
+ await this.page.goto(path);
46
+ }
47
+
48
+ /**
49
+ * Wait for page to fully load
50
+ */
51
+ async waitForLoad(state: 'load' | 'domcontentloaded' | 'networkidle' = 'load'): Promise<void> {
52
+ await this.page.waitForLoadState(state);
53
+ }
54
+
55
+ /**
56
+ * Get current URL
57
+ */
58
+ getCurrentUrl(): string {
59
+ return this.page.url();
60
+ }
61
+
62
+ /**
63
+ * Get page title
64
+ */
65
+ async getTitle(): Promise<string> {
66
+ return await this.page.title();
67
+ }
68
+
69
+ /**
70
+ * Click a button by its accessible name
71
+ */
72
+ async clickButton(name: string | RegExp): Promise<void> {
73
+ await this.page.getByRole('button', { name }).click();
74
+ }
75
+
76
+ /**
77
+ * Click a link by its accessible name
78
+ */
79
+ async clickLink(name: string | RegExp): Promise<void> {
80
+ await this.page.getByRole('link', { name }).click();
81
+ }
82
+
83
+ /**
84
+ * Fill a text field by label
85
+ */
86
+ async fillByLabel(label: string | RegExp, value: string): Promise<void> {
87
+ await this.page.getByLabel(label).fill(value);
88
+ }
89
+
90
+ /**
91
+ * Fill a text field by placeholder
92
+ */
93
+ async fillByPlaceholder(placeholder: string | RegExp, value: string): Promise<void> {
94
+ await this.page.getByPlaceholder(placeholder).fill(value);
95
+ }
96
+
97
+ /**
98
+ * Select an option from a dropdown by label
99
+ */
100
+ async selectByLabel(label: string | RegExp, value: string): Promise<void> {
101
+ await this.page.getByLabel(label).selectOption(value);
102
+ }
103
+
104
+ /**
105
+ * Check a checkbox by label
106
+ */
107
+ async checkByLabel(label: string | RegExp): Promise<void> {
108
+ await this.page.getByLabel(label).check();
109
+ }
110
+
111
+ /**
112
+ * Uncheck a checkbox by label
113
+ */
114
+ async uncheckByLabel(label: string | RegExp): Promise<void> {
115
+ await this.page.getByLabel(label).uncheck();
116
+ }
117
+
118
+ /**
119
+ * Get text content of an element
120
+ */
121
+ async getText(locator: Locator): Promise<string> {
122
+ return (await locator.textContent()) ?? '';
123
+ }
124
+
125
+ /**
126
+ * Get input value
127
+ */
128
+ async getValue(locator: Locator): Promise<string> {
129
+ return await locator.inputValue();
130
+ }
131
+
132
+ /**
133
+ * Check if element is visible
134
+ */
135
+ async isVisible(locator: Locator): Promise<boolean> {
136
+ return await locator.isVisible();
137
+ }
138
+
139
+ /**
140
+ * Check if element is enabled
141
+ */
142
+ async isEnabled(locator: Locator): Promise<boolean> {
143
+ return await locator.isEnabled();
144
+ }
145
+
146
+ /**
147
+ * Wait for element to be visible
148
+ */
149
+ async waitForVisible(locator: Locator, timeout?: number): Promise<void> {
150
+ await locator.waitFor({ state: 'visible', timeout });
151
+ }
152
+
153
+ /**
154
+ * Wait for element to be hidden
155
+ */
156
+ async waitForHidden(locator: Locator, timeout?: number): Promise<void> {
157
+ await locator.waitFor({ state: 'hidden', timeout });
158
+ }
159
+
160
+ /**
161
+ * Wait for URL to match pattern
162
+ */
163
+ async waitForUrl(url: string | RegExp, timeout?: number): Promise<void> {
164
+ await this.page.waitForURL(url, { timeout });
165
+ }
166
+
167
+ /**
168
+ * Take a screenshot
169
+ */
170
+ async screenshot(path: string): Promise<Buffer> {
171
+ return await this.page.screenshot({ path, fullPage: true });
172
+ }
173
+
174
+ /**
175
+ * Press keyboard key
176
+ */
177
+ async pressKey(key: string): Promise<void> {
178
+ await this.page.keyboard.press(key);
179
+ }
180
+
181
+ /**
182
+ * Scroll to bottom of page
183
+ */
184
+ async scrollToBottom(): Promise<void> {
185
+ await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
186
+ }
187
+
188
+ /**
189
+ * Scroll to top of page
190
+ */
191
+ async scrollToTop(): Promise<void> {
192
+ await this.page.evaluate(() => window.scrollTo(0, 0));
193
+ }
194
+
195
+ /**
196
+ * Get element by test id
197
+ */
198
+ getByTestId(testId: string): Locator {
199
+ return this.page.getByTestId(testId);
200
+ }
201
+
202
+ /**
203
+ * Get element by role
204
+ */
205
+ getByRole(
206
+ role: Parameters<Page['getByRole']>[0],
207
+ options?: Parameters<Page['getByRole']>[1],
208
+ ): Locator {
209
+ return this.page.getByRole(role, options);
210
+ }
211
+
212
+ /**
213
+ * Get element by text
214
+ */
215
+ getByText(text: string | RegExp, options?: { exact?: boolean }): Locator {
216
+ return this.page.getByText(text, options);
217
+ }
218
+
219
+ /**
220
+ * Locator shorthand
221
+ */
222
+ locator(selector: string): Locator {
223
+ return this.page.locator(selector);
224
+ }
225
+ }
@@ -0,0 +1,50 @@
1
+ import type { Locator, Page } from '@playwright/test';
2
+
3
+ import { BasePage } from './BasePage';
4
+
5
+ /**
6
+ * Login page object — a basic POM. Selectors, the login URL, and the post-login URL below are wired
7
+ * to the saucedemo.com example app (the scaffold's placeholder, set as BASE_URL in
8
+ * env/environments.json). For your own app, point BASE_URL at it and swap the four selectors +
9
+ * success URL here — the rest of the framework (lazy session auth, fixtures) stays the same.
10
+ *
11
+ * @example
12
+ * const login = new LoginPage(page);
13
+ * await login.signIn('standard_user', 'secret_sauce');
14
+ */
15
+ export class LoginPage extends BasePage {
16
+ readonly usernameInput: Locator;
17
+ readonly passwordInput: Locator;
18
+ readonly loginButton: Locator;
19
+ readonly errorMessage: Locator;
20
+
21
+ constructor(page: Page) {
22
+ super(page, '/'); // saucedemo serves the login form at the site root
23
+ this.usernameInput = page.locator('#user-name');
24
+ this.passwordInput = page.locator('#password');
25
+ this.loginButton = page.locator('#login-button');
26
+ this.errorMessage = page.locator('[data-test="error"]');
27
+ }
28
+
29
+ /** Whether the login form is visible. */
30
+ async isFormVisible(): Promise<boolean> {
31
+ return this.usernameInput.isVisible();
32
+ }
33
+
34
+ /** Fill the credentials and submit (without waiting for navigation). */
35
+ async login(username: string, password: string): Promise<void> {
36
+ await this.usernameInput.fill(username);
37
+ await this.passwordInput.fill(password);
38
+ await this.loginButton.click();
39
+ }
40
+
41
+ /**
42
+ * Navigate to the login page, sign in, and wait for the post-login page. This is the single entry
43
+ * point the lazy auth flow (fixtures/auth.ts) calls to establish a session.
44
+ */
45
+ async signIn(username: string, password: string): Promise<void> {
46
+ await this.goto();
47
+ await this.login(username, password);
48
+ await this.page.waitForURL(/\/inventory\.html/);
49
+ }
50
+ }
@@ -0,0 +1,2 @@
1
+ export { BasePage } from './BasePage';
2
+ export { LoginPage } from './LoginPage';
@@ -0,0 +1,71 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+
3
+ import { loadEnv } from '@config/loadEnv';
4
+
5
+ // Load env/environments.json (TEST_ENV → BASE_URL/API_BASE_URL → process.env) before defining config.
6
+ loadEnv();
7
+
8
+ // Plugin gates (e.g. `const maestroEnabled = process.env.MAESTRO === '1';`) are spliced in below by
9
+ // `create-pwtap add|remove`. Keep these markers — the tool rewrites only between them.
10
+ // pwtap:plugins:gates
11
+ // pwtap:plugins:gates:end
12
+
13
+ /**
14
+ * Playwright configuration. Core ships two projects — `chromium` (UI) and `api`. Each opt-in plugin
15
+ * registers its own env-gated project between the markers, so a bare `npm test` stays UI + API only.
16
+ *
17
+ * @example
18
+ * npx playwright test # chromium + api
19
+ * npx playwright test --project=chromium # UI only
20
+ * TEST_ENV=staging npx playwright test # against the staging env block
21
+ */
22
+ export default defineConfig({
23
+ testDir: './tests',
24
+ testMatch: ['**/*.spec.ts', '**/*.test.ts'],
25
+ testIgnore: ['**/setup/**', '**/helpers/**', '**/*.helper.ts'],
26
+
27
+ fullyParallel: true,
28
+ workers: process.env.CI ? 2 : undefined,
29
+ forbidOnly: !!process.env.CI,
30
+ retries: process.env.CI ? 2 : 0,
31
+ timeout: 60 * 1000,
32
+ expect: { timeout: 10 * 1000 },
33
+
34
+ reporter: [
35
+ ['html', { outputFolder: 'playwright-report', open: 'never' }],
36
+ ['json', { outputFile: 'test-results/results.json' }],
37
+ ['allure-playwright', { detail: true, outputFolder: 'allure-results', suiteTitle: false }],
38
+ ['list'],
39
+ ],
40
+ outputDir: 'test-results/',
41
+
42
+ use: {
43
+ baseURL: process.env.BASE_URL,
44
+ headless: false,
45
+ viewport: { width: 1280, height: 720 },
46
+ ignoreHTTPSErrors: true,
47
+ screenshot: 'only-on-failure',
48
+ video: 'retain-on-failure',
49
+ trace: 'retain-on-failure',
50
+ navigationTimeout: 30 * 1000,
51
+ actionTimeout: 15 * 1000,
52
+ locale: 'en-US',
53
+ timezoneId: 'UTC',
54
+ },
55
+
56
+ projects: [
57
+ {
58
+ name: 'chromium',
59
+ use: { ...devices['Desktop Chrome'] },
60
+ },
61
+ {
62
+ // Pure API project — baseURL/headers live in the layered client (api/core/ApiClient.ts,
63
+ // wired via fixtures/api.ts from API_BASE_URL), so there is nothing to configure here.
64
+ name: 'api',
65
+ testDir: './tests/api',
66
+ testMatch: /.*\.api\.ts$/,
67
+ },
68
+ // pwtap:plugins:projects
69
+ // pwtap:plugins:projects:end
70
+ ],
71
+ });
@@ -0,0 +1,19 @@
1
+ node_modules/
2
+ dist/
3
+
4
+ # Playwright / reports
5
+ playwright-report/
6
+ test-results/
7
+ allure-results/
8
+ allure-report/
9
+ playwright/.cache/
10
+
11
+ # Cached auth sessions (per-session storage state)
12
+ .auth/
13
+
14
+ # Secrets — only the *.example.json files are tracked
15
+ env/environments.json
16
+ testData/users.json
17
+
18
+ *.log
19
+ .DS_Store
@@ -0,0 +1,17 @@
1
+ {
2
+ "_note": "Named login sessions. Copy to testData/users.json (gitignored). Sessions are logged in lazily on first use (via pages/LoginPage) and cached to .auth/<key>.json; select one in a test with test.use({ session: '<key>' }). Values below are the public saucedemo.com placeholder creds so the examples run out of the box — replace with your app's users.",
3
+ "users": {
4
+ "demoUser": {
5
+ "username": "visual_user",
6
+ "password": "secret_sauce"
7
+ },
8
+ "adminUser": {
9
+ "username": "performance_glitch_user",
10
+ "password": "secret_sauce"
11
+ },
12
+ "customerUser": {
13
+ "username": "standard_user",
14
+ "password": "secret_sauce"
15
+ }
16
+ }
17
+ }