@michalfidor/playswag 1.0.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/README.md +253 -0
- package/dist/cjs/index.cjs +85 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/reporter.cjs +905 -0
- package/dist/cjs/reporter.cjs.map +1 -0
- package/dist/esm/index.js +79 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/reporter.js +896 -0
- package/dist/esm/reporter.js.map +1 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/reporter.d.ts +33 -0
- package/dist/types/types-Co4QW1no.d.ts +279 -0
- package/package.json +94 -0
package/README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# Playswag
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
> Playwright API coverage tracking against Swagger / OpenAPI specifications.
|
|
6
|
+
|
|
7
|
+
`playswag` transparently wraps Playwright's built-in `request` fixture to record every API call made during your tests, then compares the results against your OpenAPI spec(s) to report coverage across four dimensions:
|
|
8
|
+
|
|
9
|
+
| Dimension | What it measures |
|
|
10
|
+
|-----------|------------------|
|
|
11
|
+
| **Endpoints** | Which path + method combinations were called at all |
|
|
12
|
+
| **Status codes** | Which response codes defined in the spec were actually exercised |
|
|
13
|
+
| **Parameters** | Which query/path/header params were supplied |
|
|
14
|
+
| **Body properties** | Which request body fields were provided |
|
|
15
|
+
|
|
16
|
+
Works with **multiple workers** out of the box — per-worker data is collected via test attachments and aggregated in the reporter process after all tests complete.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install --save-dev @michalfidor/playswag
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`@playwright/test >=1.20.0` is a required peer dependency.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
### 1. Replace your import
|
|
33
|
+
|
|
34
|
+
```diff
|
|
35
|
+
-import { test, expect } from '@playwright/test';
|
|
36
|
+
+import { test, expect } from '@michalfidor/playswag';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
That's it. The `request` fixture is transparently wrapped — existing tests need no other changes.
|
|
40
|
+
|
|
41
|
+
### 2. Add the reporter to `playwright.config.ts`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { defineConfig } from '@playwright/test';
|
|
45
|
+
|
|
46
|
+
export default defineConfig({
|
|
47
|
+
reporter: [
|
|
48
|
+
['list'],
|
|
49
|
+
['@michalfidor/playswag/reporter', {
|
|
50
|
+
// Required: one or more spec sources (file paths or URLs)
|
|
51
|
+
specs: ['./openapi.yaml'],
|
|
52
|
+
|
|
53
|
+
// Optional
|
|
54
|
+
outputDir: './playswag-coverage',
|
|
55
|
+
outputFormats: ['console', 'json'], // default
|
|
56
|
+
|
|
57
|
+
threshold: {
|
|
58
|
+
endpoints: 80, // warn / fail if < 80% of endpoints are hit
|
|
59
|
+
statusCodes: 60,
|
|
60
|
+
},
|
|
61
|
+
failOnThreshold: false, // set true to fail the run when thresholds aren't met
|
|
62
|
+
}],
|
|
63
|
+
],
|
|
64
|
+
use: {
|
|
65
|
+
baseURL: 'https://api.example.com', // auto-detected by the reporter
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3. Run your tests
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx playwright test
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Coverage is printed to the terminal and written to `./playswag-coverage/playswag-coverage.json`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Configuration reference
|
|
81
|
+
|
|
82
|
+
All options are passed as the second element of the reporter tuple in `playwright.config.ts`.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
interface PlayswagConfig {
|
|
86
|
+
/**
|
|
87
|
+
* OpenAPI / Swagger spec source(s).
|
|
88
|
+
* Accepts local file paths (.yaml / .json), remote URLs, or an array of both.
|
|
89
|
+
* Supports Swagger 2.0 and OpenAPI 3.0 / 3.1.
|
|
90
|
+
*/
|
|
91
|
+
specs: string | string[];
|
|
92
|
+
|
|
93
|
+
/** Output directory for generated files. @default './playswag-coverage' */
|
|
94
|
+
outputDir?: string;
|
|
95
|
+
|
|
96
|
+
/** Which output formats to produce. @default ['console', 'json'] */
|
|
97
|
+
outputFormats?: Array<'console' | 'json'>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Base URL of the API under test.
|
|
101
|
+
* Auto-detected from playwright.config.ts `use.baseURL` if not provided.
|
|
102
|
+
*/
|
|
103
|
+
baseURL?: string;
|
|
104
|
+
|
|
105
|
+
/** Only track API calls whose paths match these glob patterns. */
|
|
106
|
+
includePatterns?: string[];
|
|
107
|
+
|
|
108
|
+
/** Ignore API calls whose paths match these glob patterns. */
|
|
109
|
+
excludePatterns?: string[];
|
|
110
|
+
|
|
111
|
+
consoleOutput?: {
|
|
112
|
+
enabled?: boolean; // @default true
|
|
113
|
+
showUncoveredOnly?: boolean; // @default false
|
|
114
|
+
showDetails?: boolean; // @default true — per-operation table
|
|
115
|
+
showParams?: boolean; // @default false
|
|
116
|
+
showBodyProperties?: boolean; // @default false
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
jsonOutput?: {
|
|
120
|
+
enabled?: boolean; // @default true
|
|
121
|
+
fileName?: string; // @default 'playswag-coverage.json'
|
|
122
|
+
pretty?: boolean; // @default true
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
threshold?: {
|
|
126
|
+
endpoints?: number; // 0–100
|
|
127
|
+
statusCodes?: number;
|
|
128
|
+
parameters?: number;
|
|
129
|
+
bodyProperties?: number;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* When true, the test run is marked as failed if any threshold is not met.
|
|
134
|
+
* @default false — thresholds are informational only by default
|
|
135
|
+
*/
|
|
136
|
+
failOnThreshold?: boolean;
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Per-project / per-file opt-out
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// In playwright.config.ts — disable coverage for a specific project
|
|
144
|
+
projects: [
|
|
145
|
+
{
|
|
146
|
+
name: 'no-coverage',
|
|
147
|
+
use: { playswagEnabled: false },
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
// Or inside a test file
|
|
152
|
+
test.use({ playswagEnabled: false });
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Multiple spec files
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
specs: [
|
|
161
|
+
'./specs/users.yaml',
|
|
162
|
+
'./specs/orders.yaml',
|
|
163
|
+
'https://api.example.com/openapi.json',
|
|
164
|
+
]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Duplicate `method + path` entries across files are de-duplicated (last one wins, with a console warning).
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Console output example
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
Playswag · API Coverage Report
|
|
176
|
+
2026-03-04T12:00:00.000Z · specs: openapi.yaml
|
|
177
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
┌──────────────┬─────────┬───────┬──────────────────────┐
|
|
179
|
+
│ Dimension │ Covered │ % │ Progress │
|
|
180
|
+
├──────────────┼─────────┼───────┼──────────────────────┤
|
|
181
|
+
│ Endpoints │ 5/6 │ 83.3% │ ████████████████░░░░ │
|
|
182
|
+
│ Status Codes │ 7/11 │ 63.6% │ █████████████░░░░░░░ │
|
|
183
|
+
│ Parameters │ 4/5 │ 80.0% │ ████████████████░░░░ │
|
|
184
|
+
│ Body Props │ 2/3 │ 66.7% │ █████████████░░░░░░░ │
|
|
185
|
+
└──────────────┴─────────┴───────┴──────────────────────┘
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## JSON output schema
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"specFiles": ["./openapi.yaml"],
|
|
195
|
+
"timestamp": "2026-03-04T12:00:00.000Z",
|
|
196
|
+
"summary": {
|
|
197
|
+
"endpoints": { "total": 6, "covered": 5, "percentage": 83.3 },
|
|
198
|
+
"statusCodes": { "total": 11, "covered": 7, "percentage": 63.6 },
|
|
199
|
+
"parameters": { "total": 5, "covered": 4, "percentage": 80.0 },
|
|
200
|
+
"bodyProperties":{ "total": 3, "covered": 2, "percentage": 66.7 }
|
|
201
|
+
},
|
|
202
|
+
"operations": [
|
|
203
|
+
{
|
|
204
|
+
"path": "/api/users",
|
|
205
|
+
"method": "GET",
|
|
206
|
+
"covered": true,
|
|
207
|
+
"statusCodes": {
|
|
208
|
+
"200": { "covered": true, "testRefs": ["users.spec.ts > list users"] },
|
|
209
|
+
"400": { "covered": false, "testRefs": [] }
|
|
210
|
+
},
|
|
211
|
+
"parameters": [
|
|
212
|
+
{ "name": "limit", "in": "query", "required": false, "covered": true }
|
|
213
|
+
],
|
|
214
|
+
"bodyProperties": [],
|
|
215
|
+
"testRefs": ["users.spec.ts > list users"]
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
"uncoveredOperations": [...],
|
|
219
|
+
"unmatchedHits": [...]
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## How it works
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
Worker process Main process (Reporter)
|
|
229
|
+
────────────────── ──────────────────────
|
|
230
|
+
request.get('/api/users')
|
|
231
|
+
↓ Proxy intercepts
|
|
232
|
+
records { method, url,
|
|
233
|
+
status, body, params }
|
|
234
|
+
↓
|
|
235
|
+
testInfo.attach( onTestEnd():
|
|
236
|
+
'playswag:hits', JSON reads attachment
|
|
237
|
+
) appends to aggregated list
|
|
238
|
+
↓
|
|
239
|
+
onEnd():
|
|
240
|
+
parse OpenAPI spec(s)
|
|
241
|
+
match hits → path templates
|
|
242
|
+
calculate 3-tier coverage
|
|
243
|
+
print console report
|
|
244
|
+
write JSON file
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Data flows from each worker to the reporter via Playwright's built-in test attachment IPC — no temp files, no shared state, no locking required. Works correctly with any number of parallel workers.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var test$1 = require('@playwright/test');
|
|
4
|
+
|
|
5
|
+
// src/fixture.ts
|
|
6
|
+
|
|
7
|
+
// src/constants.ts
|
|
8
|
+
var ATTACHMENT_NAME = "playswag:hits";
|
|
9
|
+
|
|
10
|
+
// src/fixture.ts
|
|
11
|
+
var INTERCEPTED_METHODS = ["get", "post", "put", "patch", "delete", "head", "fetch"];
|
|
12
|
+
function buildTrackedRequest(original, hits, testInfo) {
|
|
13
|
+
return new Proxy(original, {
|
|
14
|
+
get(target, prop, receiver) {
|
|
15
|
+
if (!INTERCEPTED_METHODS.includes(prop)) {
|
|
16
|
+
return Reflect.get(target, prop, receiver);
|
|
17
|
+
}
|
|
18
|
+
const method = prop;
|
|
19
|
+
return async (urlOrRequest, options) => {
|
|
20
|
+
let httpMethod;
|
|
21
|
+
if (method === "fetch") {
|
|
22
|
+
httpMethod = (typeof options?.["method"] === "string" ? options["method"] : "GET").toUpperCase();
|
|
23
|
+
} else {
|
|
24
|
+
httpMethod = method.toUpperCase();
|
|
25
|
+
}
|
|
26
|
+
const response = await target[method].call(target, urlOrRequest, options);
|
|
27
|
+
let queryParams;
|
|
28
|
+
const rawParams = options?.["params"];
|
|
29
|
+
if (rawParams && typeof rawParams === "object" && !Array.isArray(rawParams)) {
|
|
30
|
+
queryParams = Object.fromEntries(
|
|
31
|
+
Object.entries(rawParams).map(([k, v]) => [k, String(v)])
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
let headers;
|
|
35
|
+
const rawHeaders = options?.["headers"];
|
|
36
|
+
if (rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders)) {
|
|
37
|
+
headers = Object.fromEntries(
|
|
38
|
+
Object.entries(rawHeaders).map(([k, v]) => [k, String(v)])
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const requestBody = options?.["data"] ?? options?.["form"] ?? options?.["multipart"] ?? void 0;
|
|
42
|
+
hits.push({
|
|
43
|
+
method: httpMethod,
|
|
44
|
+
url: response.url(),
|
|
45
|
+
statusCode: response.status(),
|
|
46
|
+
requestBody,
|
|
47
|
+
queryParams,
|
|
48
|
+
headers,
|
|
49
|
+
testFile: testInfo.titlePath[0] ?? "",
|
|
50
|
+
testTitle: testInfo.title
|
|
51
|
+
});
|
|
52
|
+
return response;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
var test = test$1.test.extend({
|
|
58
|
+
playswagEnabled: [true, { option: true }],
|
|
59
|
+
trackRequest: async ({ playswagEnabled }, use, testInfo) => {
|
|
60
|
+
if (!playswagEnabled) {
|
|
61
|
+
await use((ctx) => ctx);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const hits = [];
|
|
65
|
+
await use((ctx) => buildTrackedRequest(ctx, hits, testInfo));
|
|
66
|
+
if (hits.length > 0) {
|
|
67
|
+
await testInfo.attach(ATTACHMENT_NAME, {
|
|
68
|
+
body: Buffer.from(JSON.stringify(hits), "utf8"),
|
|
69
|
+
contentType: "application/json"
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
request: async ({ request, trackRequest }, use) => {
|
|
74
|
+
await use(trackRequest(request));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
Object.defineProperty(exports, "expect", {
|
|
79
|
+
enumerable: true,
|
|
80
|
+
get: function () { return test$1.expect; }
|
|
81
|
+
});
|
|
82
|
+
exports.ATTACHMENT_NAME = ATTACHMENT_NAME;
|
|
83
|
+
exports.test = test;
|
|
84
|
+
//# sourceMappingURL=index.cjs.map
|
|
85
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/constants.ts","../../src/fixture.ts"],"names":["base"],"mappings":"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","file":"index.cjs","sourcesContent":["/** Attachment name used to pass hit data from workers to the reporter process. */\nexport const ATTACHMENT_NAME = 'playswag:hits';\n","import {\n test as base,\n expect,\n type APIRequestContext,\n type APIResponse,\n type TestInfo,\n} from '@playwright/test';\nimport type { EndpointHit } from './types.js';\nimport { ATTACHMENT_NAME } from './constants.js';\n\nexport { expect };\nexport { ATTACHMENT_NAME } from './constants.js';\n\nconst INTERCEPTED_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'fetch'] as const;\ntype HttpMethod = (typeof INTERCEPTED_METHODS)[number];\n\n/**\n * Build a Proxy around an APIRequestContext that records every HTTP call.\n * Generic so the original type (e.g. a CustomAPIRequest subtype) is preserved.\n */\nfunction buildTrackedRequest<T extends APIRequestContext>(\n original: T,\n hits: EndpointHit[],\n testInfo: TestInfo\n): T {\n return new Proxy(original, {\n get(target, prop, receiver) {\n if (!INTERCEPTED_METHODS.includes(prop as HttpMethod)) {\n return Reflect.get(target, prop, receiver);\n }\n\n const method = prop as HttpMethod;\n\n return async (urlOrRequest: string | object, options?: Record<string, unknown>): Promise<APIResponse> => {\n let httpMethod: string;\n if (method === 'fetch') {\n httpMethod = (\n typeof options?.['method'] === 'string' ? options['method'] : 'GET'\n ).toUpperCase();\n } else {\n httpMethod = method.toUpperCase();\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const response: APIResponse = await (target[method] as any).call(target, urlOrRequest, options);\n\n let queryParams: Record<string, string> | undefined;\n const rawParams = options?.['params'];\n if (rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams)) {\n queryParams = Object.fromEntries(\n Object.entries(rawParams as Record<string, unknown>).map(([k, v]) => [k, String(v)])\n );\n }\n\n let headers: Record<string, string> | undefined;\n const rawHeaders = options?.['headers'];\n if (rawHeaders && typeof rawHeaders === 'object' && !Array.isArray(rawHeaders)) {\n headers = Object.fromEntries(\n Object.entries(rawHeaders as Record<string, string>).map(([k, v]) => [k, String(v)])\n );\n }\n\n const requestBody = options?.['data'] ?? options?.['form'] ?? options?.['multipart'] ?? undefined;\n\n hits.push({\n method: httpMethod,\n url: response.url(),\n statusCode: response.status(),\n requestBody,\n queryParams,\n headers,\n testFile: testInfo.titlePath[0] ?? '',\n testTitle: testInfo.title,\n });\n\n return response;\n };\n },\n }) as T;\n}\n\n\ntype PlayswagOptions = {\n /** Set to false to disable coverage tracking for this project/file. @default true */\n playswagEnabled: boolean;\n};\n\n/**\n * Fixtures added by playswag, available in any test or fixture that extends `test`.\n *\n * `trackRequest` wraps any `APIRequestContext` (including custom subtypes) so\n * that every HTTP call made through it is recorded for coverage. All hits from\n * all wrapped contexts within a single test are combined into one attachment.\n *\n * @example\n * // In your context fixture:\n * myServiceContext: async ({ trackRequest }, use) => {\n * const raw = await ContextFactory.getContextByUserAccessToken('user');\n * await use(trackRequest(raw));\n * },\n */\nexport type PlayswagFixtures = {\n trackRequest: <T extends APIRequestContext>(ctx: T) => T;\n};\n\n/**\n * `test` extended from `@playwright/test` with transparent API coverage tracking.\n *\n * Just replace:\n * import { test, expect } from '@playwright/test';\n * with:\n * import { test, expect } from '@michalfidor/playswag';\n *\n * The `request` fixture is automatically wrapped — no other changes needed.\n *\n * For tests that use custom `APIRequestContext` objects (e.g. created via\n * `request.newContext()`), use the `trackRequest` fixture to wrap them:\n * myContext: async ({ trackRequest }, use) => { use(trackRequest(raw)); }\n *\n * Disable tracking per-project or per-file with:\n * test.use({ playswagEnabled: false });\n */\nexport const test = base.extend<PlayswagOptions & PlayswagFixtures>({\n playswagEnabled: [true, { option: true }],\n\n trackRequest: async (\n { playswagEnabled }: { playswagEnabled: boolean },\n use: (fn: <T extends APIRequestContext>(ctx: T) => T) => Promise<void>,\n testInfo: TestInfo\n ) => {\n if (!playswagEnabled) {\n await use(<T extends APIRequestContext>(ctx: T) => ctx);\n return;\n }\n\n const hits: EndpointHit[] = [];\n await use(<T extends APIRequestContext>(ctx: T) => buildTrackedRequest(ctx, hits, testInfo));\n\n if (hits.length > 0) {\n await testInfo.attach(ATTACHMENT_NAME, {\n body: Buffer.from(JSON.stringify(hits), 'utf8'),\n contentType: 'application/json',\n });\n }\n },\n\n request: async (\n { request, trackRequest }: { request: APIRequestContext; trackRequest: <T extends APIRequestContext>(ctx: T) => T },\n use: (r: APIRequestContext) => Promise<void>\n ) => {\n await use(trackRequest(request));\n },\n});\n"]}
|