@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 ADDED
@@ -0,0 +1,253 @@
1
+ # Playswag
2
+
3
+ ![playswag logo](assets/logo.png)
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"]}