@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.
- package/core-manifest.json +50 -0
- package/dist/commands/add.d.ts +8 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +9 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/create.d.ts +12 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +114 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/remove.d.ts +7 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/remove.js +9 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/injectors/assets.d.ts +6 -0
- package/dist/injectors/assets.d.ts.map +1 -0
- package/dist/injectors/assets.js +53 -0
- package/dist/injectors/assets.js.map +1 -0
- package/dist/injectors/envJson.d.ts +6 -0
- package/dist/injectors/envJson.d.ts.map +1 -0
- package/dist/injectors/envJson.js +39 -0
- package/dist/injectors/envJson.js.map +1 -0
- package/dist/injectors/fixturesBarrel.d.ts +11 -0
- package/dist/injectors/fixturesBarrel.d.ts.map +1 -0
- package/dist/injectors/fixturesBarrel.js +66 -0
- package/dist/injectors/fixturesBarrel.js.map +1 -0
- package/dist/injectors/packageJson.d.ts +6 -0
- package/dist/injectors/packageJson.d.ts.map +1 -0
- package/dist/injectors/packageJson.js +25 -0
- package/dist/injectors/packageJson.js.map +1 -0
- package/dist/injectors/pwConfig.d.ts +10 -0
- package/dist/injectors/pwConfig.d.ts.map +1 -0
- package/dist/injectors/pwConfig.js +40 -0
- package/dist/injectors/pwConfig.js.map +1 -0
- package/dist/manifest.d.ts +65 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +22 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugin-apply.d.ts +13 -0
- package/dist/plugin-apply.d.ts.map +1 -0
- package/dist/plugin-apply.js +89 -0
- package/dist/plugin-apply.js.map +1 -0
- package/dist/prompts.d.ts +22 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +65 -0
- package/dist/prompts.js.map +1 -0
- package/dist/registry.d.ts +26 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +34 -0
- package/dist/registry.js.map +1 -0
- package/dist/util/fs.d.ts +13 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/util/fs.js +32 -0
- package/dist/util/fs.js.map +1 -0
- package/dist/util/log.d.ts +8 -0
- package/dist/util/log.d.ts.map +1 -0
- package/dist/util/log.js +9 -0
- package/dist/util/log.js.map +1 -0
- package/dist/util/markers.d.ts +20 -0
- package/dist/util/markers.d.ts.map +1 -0
- package/dist/util/markers.js +51 -0
- package/dist/util/markers.js.map +1 -0
- package/dist/util/run.d.ts +8 -0
- package/dist/util/run.d.ts.map +1 -0
- package/dist/util/run.js +27 -0
- package/dist/util/run.js.map +1 -0
- package/package.json +31 -0
- package/template/.commitlintrc.json +3 -0
- package/template/.prettierrc +17 -0
- package/template/api/core/ApiClient.ts +99 -0
- package/template/api/core/types.ts +29 -0
- package/template/api/index.ts +4 -0
- package/template/api/models/pet.ts +24 -0
- package/template/api/services/PetService.ts +67 -0
- package/template/config/envUtils.ts +70 -0
- package/template/config/index.ts +2 -0
- package/template/config/loadEnv.ts +110 -0
- package/template/env/environments.example.json +20 -0
- package/template/eslint.config.js +74 -0
- package/template/fixtures/api.ts +46 -0
- package/template/fixtures/auth.ts +150 -0
- package/template/fixtures/index.ts +38 -0
- package/template/fixtures/ui.ts +112 -0
- package/template/pages/BasePage.ts +225 -0
- package/template/pages/LoginPage.ts +50 -0
- package/template/pages/index.ts +2 -0
- package/template/playwright.config.ts +71 -0
- package/template/templates/gitignore +19 -0
- package/template/testData/users.example.json +17 -0
- package/template/tests/api/pet.api.ts +60 -0
- package/template/tests/example/authSession.spec.ts +34 -0
- package/template/tests/example/login.spec.ts +50 -0
- package/template/tsconfig.json +33 -0
- package/template/utils/apiUtils.ts +224 -0
- package/template/utils/dateUtils.ts +158 -0
- package/template/utils/index.ts +15 -0
- package/template/utils/stringUtils.ts +192 -0
- package/template/utils/uiUtils.ts +236 -0
- package/template/utils/validationUtils.ts +190 -0
- 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,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
|
+
}
|