@solidxai/core 0.1.15 → 0.1.16-beta.1

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 (176) hide show
  1. package/CHANGELOG.md +1293 -0
  2. package/dist/commands/run-tests.command.d.ts +3 -1
  3. package/dist/commands/run-tests.command.d.ts.map +1 -1
  4. package/dist/commands/run-tests.command.js +6 -2
  5. package/dist/commands/run-tests.command.js.map +1 -1
  6. package/dist/controllers/test-run.controller.d.ts +12 -0
  7. package/dist/controllers/test-run.controller.d.ts.map +1 -0
  8. package/dist/controllers/test-run.controller.js +54 -0
  9. package/dist/controllers/test-run.controller.js.map +1 -0
  10. package/dist/dtos/test-run-request.dto.d.ts +35 -0
  11. package/dist/dtos/test-run-request.dto.d.ts.map +1 -0
  12. package/dist/dtos/test-run-request.dto.js +120 -0
  13. package/dist/dtos/test-run-request.dto.js.map +1 -0
  14. package/dist/guards/access-token.guard.d.ts +5 -1
  15. package/dist/guards/access-token.guard.d.ts.map +1 -1
  16. package/dist/guards/access-token.guard.js +20 -1
  17. package/dist/guards/access-token.guard.js.map +1 -1
  18. package/dist/index.d.ts +12 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/interfaces/active-user-data.interface.d.ts +1 -0
  23. package/dist/interfaces/active-user-data.interface.d.ts.map +1 -1
  24. package/dist/interfaces/active-user-data.interface.js.map +1 -1
  25. package/dist/jobs/database/test-run-queue-options-database.d.ts +8 -0
  26. package/dist/jobs/database/test-run-queue-options-database.d.ts.map +1 -0
  27. package/dist/jobs/database/test-run-queue-options-database.js +10 -0
  28. package/dist/jobs/database/test-run-queue-options-database.js.map +1 -0
  29. package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts +12 -0
  30. package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts.map +1 -0
  31. package/dist/jobs/database/test-run-queue-publisher-database.service.js +39 -0
  32. package/dist/jobs/database/test-run-queue-publisher-database.service.js.map +1 -0
  33. package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts +26 -0
  34. package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts.map +1 -0
  35. package/dist/jobs/database/test-run-queue-subscriber-database.service.js +120 -0
  36. package/dist/jobs/database/test-run-queue-subscriber-database.service.js.map +1 -0
  37. package/dist/resources/images/solid-core-menu-icon.png +0 -0
  38. package/dist/seeders/seed-data/solid-core-metadata.json +4 -2
  39. package/dist/services/access-token-denylist.service.d.ts +16 -0
  40. package/dist/services/access-token-denylist.service.d.ts.map +1 -0
  41. package/dist/services/access-token-denylist.service.js +74 -0
  42. package/dist/services/access-token-denylist.service.js.map +1 -0
  43. package/dist/services/authentication.service.d.ts +9 -1
  44. package/dist/services/authentication.service.d.ts.map +1 -1
  45. package/dist/services/authentication.service.js +39 -4
  46. package/dist/services/authentication.service.js.map +1 -1
  47. package/dist/services/import-transaction.service.d.ts +1 -1
  48. package/dist/services/import-transaction.service.d.ts.map +1 -1
  49. package/dist/services/import-transaction.service.js +13 -23
  50. package/dist/services/import-transaction.service.js.map +1 -1
  51. package/dist/services/module-metadata.service.js +4 -4
  52. package/dist/services/module-metadata.service.js.map +1 -1
  53. package/dist/services/settings/default-settings-provider.service.d.ts +20 -0
  54. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  55. package/dist/services/settings/default-settings-provider.service.js +11 -0
  56. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  57. package/dist/services/workflow-secret.service.d.ts +3 -0
  58. package/dist/services/workflow-secret.service.d.ts.map +1 -1
  59. package/dist/services/workflow-secret.service.js +25 -0
  60. package/dist/services/workflow-secret.service.js.map +1 -1
  61. package/dist/solid-core.module.d.ts.map +1 -1
  62. package/dist/solid-core.module.js +9 -0
  63. package/dist/solid-core.module.js.map +1 -1
  64. package/dist/testing/adapters/ui/browser-provisioner.d.ts +5 -0
  65. package/dist/testing/adapters/ui/browser-provisioner.d.ts.map +1 -0
  66. package/dist/testing/adapters/ui/browser-provisioner.js +102 -0
  67. package/dist/testing/adapters/ui/browser-provisioner.js.map +1 -0
  68. package/dist/testing/adapters/ui/playwright-adapter.d.ts +40 -1
  69. package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
  70. package/dist/testing/adapters/ui/playwright-adapter.js +185 -3
  71. package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
  72. package/dist/testing/adapters/ui/ui.types.d.ts +6 -0
  73. package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
  74. package/dist/testing/adapters/ui/ui.types.js.map +1 -1
  75. package/dist/testing/contracts/runtime-context.types.d.ts +2 -0
  76. package/dist/testing/contracts/runtime-context.types.d.ts.map +1 -1
  77. package/dist/testing/contracts/runtime-context.types.js.map +1 -1
  78. package/dist/testing/core/interpolation.d.ts +1 -0
  79. package/dist/testing/core/interpolation.d.ts.map +1 -1
  80. package/dist/testing/core/interpolation.js +28 -68
  81. package/dist/testing/core/interpolation.js.map +1 -1
  82. package/dist/testing/core/path-resolution.spec.d.ts +2 -0
  83. package/dist/testing/core/path-resolution.spec.d.ts.map +1 -0
  84. package/dist/testing/core/path-resolution.spec.js +106 -0
  85. package/dist/testing/core/path-resolution.spec.js.map +1 -0
  86. package/dist/testing/core/path-segments.d.ts +2 -0
  87. package/dist/testing/core/path-segments.d.ts.map +1 -0
  88. package/dist/testing/core/path-segments.js +70 -0
  89. package/dist/testing/core/path-segments.js.map +1 -0
  90. package/dist/testing/core/resource-store.d.ts.map +1 -1
  91. package/dist/testing/core/resource-store.js +5 -2
  92. package/dist/testing/core/resource-store.js.map +1 -1
  93. package/dist/testing/core/testing-engine.d.ts.map +1 -1
  94. package/dist/testing/core/testing-engine.js +16 -0
  95. package/dist/testing/core/testing-engine.js.map +1 -1
  96. package/dist/testing/reporter/artifact-sink.d.ts +14 -0
  97. package/dist/testing/reporter/artifact-sink.d.ts.map +1 -0
  98. package/dist/testing/reporter/artifact-sink.js +3 -0
  99. package/dist/testing/reporter/artifact-sink.js.map +1 -0
  100. package/dist/testing/reporter/console-reporter.d.ts.map +1 -1
  101. package/dist/testing/reporter/console-reporter.js +7 -0
  102. package/dist/testing/reporter/console-reporter.js.map +1 -1
  103. package/dist/testing/reporter/file-service-artifact-sink.d.ts +22 -0
  104. package/dist/testing/reporter/file-service-artifact-sink.d.ts.map +1 -0
  105. package/dist/testing/reporter/file-service-artifact-sink.js +31 -0
  106. package/dist/testing/reporter/file-service-artifact-sink.js.map +1 -0
  107. package/dist/testing/reporter/lifecycle-events.types.d.ts +67 -0
  108. package/dist/testing/reporter/lifecycle-events.types.d.ts.map +1 -0
  109. package/dist/testing/reporter/lifecycle-events.types.js +3 -0
  110. package/dist/testing/reporter/lifecycle-events.types.js.map +1 -0
  111. package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts +77 -0
  112. package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts.map +1 -0
  113. package/dist/testing/reporter/lifecycle-webhook-reporter.js +275 -0
  114. package/dist/testing/reporter/lifecycle-webhook-reporter.js.map +1 -0
  115. package/dist/testing/reporter/progress-reporter.d.ts +3 -3
  116. package/dist/testing/reporter/progress-reporter.d.ts.map +1 -1
  117. package/dist/testing/reporter/progress-reporter.js.map +1 -1
  118. package/dist/testing/reporter/reporter.types.d.ts +13 -1
  119. package/dist/testing/reporter/reporter.types.d.ts.map +1 -1
  120. package/dist/testing/reporter/reporter.types.js.map +1 -1
  121. package/dist/testing/runner/read-scenarios-file.d.ts +9 -0
  122. package/dist/testing/runner/read-scenarios-file.d.ts.map +1 -0
  123. package/dist/testing/runner/read-scenarios-file.js +56 -0
  124. package/dist/testing/runner/read-scenarios-file.js.map +1 -0
  125. package/dist/testing/runner/run-from-metadata.d.ts +7 -2
  126. package/dist/testing/runner/run-from-metadata.d.ts.map +1 -1
  127. package/dist/testing/runner/run-from-metadata.js +57 -6
  128. package/dist/testing/runner/run-from-metadata.js.map +1 -1
  129. package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
  130. package/dist/testing/steps/ui/navigation.step.js +12 -12
  131. package/dist/testing/steps/ui/navigation.step.js.map +1 -1
  132. package/package.json +4 -2
  133. package/postman/instant-logout.postman_collection.json +1493 -0
  134. package/resources/images/solid-core-menu-icon.png +0 -0
  135. package/src/commands/run-tests.command.ts +3 -0
  136. package/src/controllers/test-run.controller.ts +44 -0
  137. package/src/dtos/test-run-request.dto.ts +130 -0
  138. package/src/guards/access-token.guard.ts +48 -0
  139. package/src/index.ts +21 -1
  140. package/src/interfaces/active-user-data.interface.ts +8 -0
  141. package/src/jobs/database/test-run-queue-options-database.ts +9 -0
  142. package/src/jobs/database/test-run-queue-publisher-database.service.ts +24 -0
  143. package/src/jobs/database/test-run-queue-subscriber-database.service.ts +122 -0
  144. package/src/seeders/seed-data/solid-core-metadata.json +4 -2
  145. package/src/services/access-token-denylist.service.ts +145 -0
  146. package/src/services/authentication.service.ts +118 -2
  147. package/src/services/import-transaction.service.ts +19 -36
  148. package/src/services/module-metadata.service.ts +4 -4
  149. package/src/services/settings/default-settings-provider.service.ts +12 -0
  150. package/src/services/workflow-secret.service.ts +61 -2
  151. package/src/solid-core.module.ts +11 -0
  152. package/src/testing/README.md +44 -1
  153. package/src/testing/adapters/ui/browser-provisioner.ts +115 -0
  154. package/src/testing/adapters/ui/playwright-adapter.ts +255 -5
  155. package/src/testing/adapters/ui/ui.types.ts +14 -0
  156. package/src/testing/contracts/runtime-context.types.ts +6 -0
  157. package/src/testing/core/interpolation.ts +39 -69
  158. package/src/testing/core/path-resolution.spec.ts +133 -0
  159. package/src/testing/core/path-segments.ts +81 -0
  160. package/src/testing/core/resource-store.ts +8 -2
  161. package/src/testing/core/testing-engine.ts +22 -0
  162. package/src/testing/reporter/artifact-sink.ts +16 -0
  163. package/src/testing/reporter/console-reporter.ts +9 -0
  164. package/src/testing/reporter/file-service-artifact-sink.ts +46 -0
  165. package/src/testing/reporter/lifecycle-events.types.ts +103 -0
  166. package/src/testing/reporter/lifecycle-webhook-reporter.ts +385 -0
  167. package/src/testing/reporter/progress-reporter.ts +3 -1
  168. package/src/testing/reporter/reporter.types.ts +10 -1
  169. package/src/testing/runner/read-scenarios-file.ts +44 -0
  170. package/src/testing/runner/run-from-metadata.ts +102 -9
  171. package/src/testing/steps/ui/navigation.step.ts +24 -20
  172. package/dist/services/access-token-storage.service.d.ts +0 -1
  173. package/dist/services/access-token-storage.service.d.ts.map +0 -1
  174. package/dist/services/access-token-storage.service.js +0 -1
  175. package/dist/services/access-token-storage.service.js.map +0 -1
  176. package/src/services/access-token-storage.service.ts +0 -0
@@ -1,28 +1,73 @@
1
- import type { Browser, BrowserContext, Page } from "playwright";
1
+ import type { Browser, BrowserContext, Page, ConsoleMessage, Response, Request } from "playwright";
2
2
 
3
- import type { PlaywrightAdapterOptions } from "./ui.types";
3
+ import type { CaptureOptions, PlaywrightAdapterOptions } from "./ui.types";
4
4
 
5
5
  function isAbsoluteUrl(url: string): boolean {
6
6
  return /^https?:\/\//i.test(url);
7
7
  }
8
8
 
9
9
  export const DEFAULT_UI_TIMEOUT_MS = 30_000;
10
+ /** Keep the buffers bounded so a chatty page can't grow memory without limit. */
11
+ const MAX_CONSOLE_ENTRIES = 500;
12
+ const MAX_NETWORK_ENTRIES = 500;
13
+ const MAX_NETWORK_BODY_CHARS = 16_000;
14
+ const STATIC_EXT_RE = /\.(?:css|js|mjs|cjs|map|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|eot|otf|mp4|webm|mp3|wav|pdf|zip)$/i;
15
+
16
+ export interface ConsoleLogEntry {
17
+ type: string;
18
+ text: string;
19
+ location?: string;
20
+ }
21
+
22
+ export interface NetworkLogEntry {
23
+ method: string;
24
+ url: string;
25
+ resourceType?: string;
26
+ requestHeaders?: Record<string, string>;
27
+ requestBody?: string;
28
+ status?: number;
29
+ ok?: boolean;
30
+ responseHeaders?: Record<string, string>;
31
+ responseBody?: string;
32
+ durationMs?: number;
33
+ failure?: string;
34
+ }
35
+
36
+ /** One artifact to hand to the reporter's `attach()` on scenario failure. */
37
+ export interface FailureArtifact {
38
+ name: string;
39
+ contentType: string;
40
+ data: Buffer | string;
41
+ }
10
42
 
11
43
  export class PlaywrightAdapter {
12
44
  private readonly baseUrl?: string;
13
45
  private readonly headless: boolean;
14
46
  private readonly defaultTimeoutMs: number;
15
47
  private readonly navigationTimeoutMs: number;
48
+ private readonly capture: CaptureOptions;
49
+ private readonly recordVideo: boolean;
16
50
  private browser?: Browser;
17
51
  private context?: BrowserContext;
18
52
  public page?: Page;
19
53
 
54
+ /** Per-scenario ring buffers; reset via {@link resetCapture} at each scenario start. */
55
+ private consoleBuf: ConsoleLogEntry[] = [];
56
+ private networkBuf: NetworkLogEntry[] = [];
57
+
58
+ /** Whole-run video: temp dir Playwright writes the webm into, and the finalized bytes. */
59
+ private videoDir?: string;
60
+ private runVideo?: FailureArtifact;
61
+ private readonly requestStartedAt = new Map<Request, number>();
62
+
20
63
  constructor(opts?: PlaywrightAdapterOptions) {
21
64
  this.baseUrl = opts?.baseUrl;
22
65
  this.headless = opts?.headless ?? true;
23
66
  this.defaultTimeoutMs = opts?.defaultTimeoutMs ?? DEFAULT_UI_TIMEOUT_MS;
24
67
  this.navigationTimeoutMs =
25
68
  opts?.navigationTimeoutMs ?? this.defaultTimeoutMs;
69
+ this.capture = opts?.capture ?? {};
70
+ this.recordVideo = opts?.recordVideo ?? true;
26
71
  }
27
72
 
28
73
  isHeadless(): boolean {
@@ -48,13 +93,158 @@ export class PlaywrightAdapter {
48
93
  async start(): Promise<void> {
49
94
  const { chromium } = await import('playwright');
50
95
  this.browser = await chromium.launch({ headless: this.headless });
51
- this.context = await this.browser.newContext();
96
+ // Record a whole-run video only when this run enables it; it is still only KEPT when
97
+ // the run fails (see stop({ keepVideo }) / the runner). The temp dir has to exist
98
+ // before the context is created, since recordVideo is a context-creation option.
99
+ if (this.recordVideo) {
100
+ const os = await import('os');
101
+ const path = await import('path');
102
+ const fs = await import('fs');
103
+ this.videoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solid-run-video-'));
104
+ }
105
+ this.context = await this.browser.newContext(
106
+ this.videoDir ? { recordVideo: { dir: this.videoDir } } : {},
107
+ );
52
108
  this.context.setDefaultTimeout(this.defaultTimeoutMs);
53
109
  this.context.setDefaultNavigationTimeout(this.navigationTimeoutMs);
54
110
  this.page = await this.context.newPage();
111
+ this.attachCaptureListeners(this.page);
112
+ }
113
+
114
+ /** Whole-run video, available after {@link stop} has finalized the recording. */
115
+ getRunVideo(): FailureArtifact | undefined {
116
+ return this.runVideo;
117
+ }
118
+
119
+ /**
120
+ * Wire console/network listeners once per page. Events accumulate in the per-scenario
121
+ * buffers and are only surfaced (via {@link collectFailureArtifacts}) when a scenario
122
+ * fails — passing scenarios reset and discard them.
123
+ */
124
+ private attachCaptureListeners(page: Page): void {
125
+ if (this.capture.console) {
126
+ page.on("console", (msg: ConsoleMessage) => {
127
+ if (this.consoleBuf.length >= MAX_CONSOLE_ENTRIES) return;
128
+ const loc = msg.location();
129
+ this.consoleBuf.push({
130
+ type: msg.type(),
131
+ text: msg.text(),
132
+ location: loc?.url ? `${loc.url}:${loc.lineNumber ?? 0}` : undefined,
133
+ });
134
+ });
135
+ page.on("pageerror", (err: Error) => {
136
+ if (this.consoleBuf.length >= MAX_CONSOLE_ENTRIES) return;
137
+ this.consoleBuf.push({ type: "pageerror", text: err?.stack || err?.message || String(err) });
138
+ });
139
+ }
140
+
141
+ if (this.capture.network) {
142
+ page.on("request", (req: Request) => {
143
+ this.requestStartedAt.set(req, Date.now());
144
+ });
145
+ page.on("response", async (res: Response) => {
146
+ if (this.networkBuf.length >= MAX_NETWORK_ENTRIES) return;
147
+ const req = res.request();
148
+ const captureDetail = shouldCaptureDetailedNetworkEntry(req);
149
+ const requestHeaders = captureDetail ? await req.allHeaders().catch(() => undefined) : undefined;
150
+ const responseHeaders = captureDetail ? await res.allHeaders().catch(() => undefined) : undefined;
151
+ const responseBody = captureDetail ? await this.readResponseBody(res, responseHeaders) : undefined;
152
+ const startedAt = this.requestStartedAt.get(req);
153
+ this.requestStartedAt.delete(req);
154
+ this.networkBuf.push({
155
+ method: req.method(),
156
+ url: res.url(),
157
+ resourceType: req.resourceType(),
158
+ requestHeaders,
159
+ requestBody: captureDetail ? truncateText(req.postData() ?? undefined) : undefined,
160
+ status: res.status(),
161
+ ok: res.ok(),
162
+ responseHeaders,
163
+ responseBody,
164
+ durationMs: startedAt != null ? Date.now() - startedAt : undefined,
165
+ });
166
+ });
167
+ page.on("requestfailed", async (req: Request) => {
168
+ if (this.networkBuf.length >= MAX_NETWORK_ENTRIES) return;
169
+ const startedAt = this.requestStartedAt.get(req);
170
+ this.requestStartedAt.delete(req);
171
+ const captureDetail = shouldCaptureDetailedNetworkEntry(req);
172
+ this.networkBuf.push({
173
+ method: req.method(),
174
+ url: req.url(),
175
+ resourceType: req.resourceType(),
176
+ requestHeaders: captureDetail ? await req.allHeaders().catch(() => undefined) : undefined,
177
+ requestBody: captureDetail ? truncateText(req.postData() ?? undefined) : undefined,
178
+ failure: req.failure()?.errorText ?? "request failed",
179
+ durationMs: startedAt != null ? Date.now() - startedAt : undefined,
180
+ });
181
+ });
182
+ }
183
+ }
184
+
185
+ /** Clear the per-scenario buffers. Called by the engine at each scenario start. */
186
+ resetCapture(): void {
187
+ this.consoleBuf = [];
188
+ this.networkBuf = [];
189
+ this.requestStartedAt.clear();
190
+ }
191
+
192
+ getConsoleLog(): ConsoleLogEntry[] {
193
+ return this.consoleBuf;
194
+ }
195
+
196
+ getNetworkLog(): NetworkLogEntry[] {
197
+ return this.networkBuf;
198
+ }
199
+
200
+ private async readResponseBody(res: Response, headers?: Record<string, string>): Promise<string | undefined> {
201
+ const contentType = String(headers?.["content-type"] ?? headers?.["Content-Type"] ?? "").toLowerCase();
202
+ const contentLength = Number(headers?.["content-length"] ?? headers?.["Content-Length"] ?? 0);
203
+ if (contentLength && contentLength > MAX_NETWORK_BODY_CHARS) {
204
+ return `[body omitted: ${contentLength} bytes]`;
205
+ }
206
+ if (!isTextLikeContentType(contentType)) return undefined;
207
+ try {
208
+ return truncateText(await res.text());
209
+ } catch {
210
+ return undefined;
211
+ }
55
212
  }
56
213
 
57
- async stop(): Promise<void> {
214
+ /**
215
+ * Build the text artifacts to attach when the current scenario has FAILED (console +
216
+ * network logs). Honors this adapter's capture flags. Returns an empty list when no page
217
+ * is open (e.g. an API scenario). Failure screenshots were removed — the whole-run video
218
+ * (kept only on failure) is the visual failure artifact now.
219
+ */
220
+ async collectFailureArtifacts(): Promise<FailureArtifact[]> {
221
+ if (!this.page) return [];
222
+ const artifacts: FailureArtifact[] = [];
223
+
224
+ if (this.capture.console && this.consoleBuf.length) {
225
+ artifacts.push({
226
+ name: "console.json",
227
+ contentType: "application/json",
228
+ data: JSON.stringify(this.consoleBuf),
229
+ });
230
+ }
231
+
232
+ if (this.capture.network && this.networkBuf.length) {
233
+ artifacts.push({
234
+ name: "network.json",
235
+ contentType: "application/json",
236
+ data: JSON.stringify(this.networkBuf),
237
+ });
238
+ }
239
+
240
+ return artifacts;
241
+ }
242
+
243
+ async stop(opts?: { keepVideo?: boolean }): Promise<void> {
244
+ const keepVideo = opts?.keepVideo ?? false;
245
+ // Grab the video handle before the context closes; its file is only finalized on close().
246
+ const video = this.videoDir ? this.page?.video() : undefined;
247
+
58
248
  try {
59
249
  if (this.context) {
60
250
  await this.context.close();
@@ -64,6 +254,25 @@ export class PlaywrightAdapter {
64
254
  this.page = undefined;
65
255
  }
66
256
 
257
+ if (video && this.videoDir) {
258
+ try {
259
+ const fs = await import('fs');
260
+ // Only read the recording into memory when the caller wants to keep it (i.e. the
261
+ // run FAILED). Passing runs discard the webm unread — no wasted IO/memory.
262
+ if (keepVideo) {
263
+ const videoPath = await video.path();
264
+ const data = await fs.promises.readFile(videoPath);
265
+ this.runVideo = { name: 'run.webm', contentType: 'video/webm', data };
266
+ }
267
+ // Clean up the temp recording directory either way.
268
+ await fs.promises.rm(this.videoDir, { recursive: true, force: true }).catch(() => {});
269
+ } catch {
270
+ // A video read failure must never mask the real run result.
271
+ } finally {
272
+ this.videoDir = undefined;
273
+ }
274
+ }
275
+
67
276
  try {
68
277
  if (this.browser) {
69
278
  await this.browser.close();
@@ -76,8 +285,49 @@ export class PlaywrightAdapter {
76
285
  resolveUrl(url: string): string {
77
286
  if (isAbsoluteUrl(url)) return url;
78
287
  if (this.baseUrl) {
79
- return new URL(url, this.baseUrl).toString();
288
+ // Treat the configured base as a prefix and keep it whole, matching how
289
+ // the API adapter combines axios `baseURL` with a relative url. Resolving
290
+ // through `new URL()` follows RFC 3986 and discards any path on the base,
291
+ // so an application registered at https://host/myapp would send "/login"
292
+ // to https://host/login instead of https://host/myapp/login.
293
+ return `${this.baseUrl.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`;
80
294
  }
81
295
  return url;
82
296
  }
83
297
  }
298
+
299
+ function truncateText(value?: string): string | undefined {
300
+ if (!value) return undefined;
301
+ if (value.length <= MAX_NETWORK_BODY_CHARS) return value;
302
+ return `${value.slice(0, MAX_NETWORK_BODY_CHARS)}\n… [truncated ${value.length - MAX_NETWORK_BODY_CHARS} chars]`;
303
+ }
304
+
305
+ function isTextLikeContentType(contentType: string): boolean {
306
+ if (!contentType) return true;
307
+ return (
308
+ contentType.includes("json") ||
309
+ contentType.includes("text/") ||
310
+ contentType.includes("javascript") ||
311
+ contentType.includes("xml") ||
312
+ contentType.includes("html") ||
313
+ contentType.includes("x-www-form-urlencoded")
314
+ );
315
+ }
316
+
317
+ function shouldCaptureDetailedNetworkEntry(req: Request): boolean {
318
+ const resourceType = req.resourceType().toLowerCase();
319
+ if (resourceType === "xhr" || resourceType === "fetch") return true;
320
+
321
+ const method = req.method().toUpperCase();
322
+ if (method !== "GET" && method !== "HEAD") return true;
323
+
324
+ const url = req.url().toLowerCase();
325
+ try {
326
+ const pathname = new URL(url).pathname.toLowerCase();
327
+ if (STATIC_EXT_RE.test(pathname)) return false;
328
+ } catch {
329
+ if (STATIC_EXT_RE.test(url)) return false;
330
+ }
331
+
332
+ return resourceType === "document";
333
+ }
@@ -1,6 +1,20 @@
1
+ /**
2
+ * Per-run capture toggles for the Playwright adapter. Console/network events are
3
+ * buffered while a scenario runs and only flushed (attached to the reporter) when the
4
+ * scenario FAILS; passing scenarios discard them. API scenarios never open a page, so
5
+ * nothing is captured. The whole-run video may be enabled per run and is kept only when
6
+ * the run fails.
7
+ */
8
+ export interface CaptureOptions {
9
+ console?: boolean;
10
+ network?: boolean;
11
+ }
12
+
1
13
  export interface PlaywrightAdapterOptions {
2
14
  headless?: boolean;
3
15
  baseUrl?: string;
4
16
  defaultTimeoutMs?: number;
5
17
  navigationTimeoutMs?: number;
18
+ capture?: CaptureOptions;
19
+ recordVideo?: boolean;
6
20
  }
@@ -30,6 +30,12 @@ export interface TestContext {
30
30
  reporter: Reporter;
31
31
  specRegistry?: SpecRegistry;
32
32
  testData?: Record<string, Record<string, any>>;
33
+ env?: Record<string, string>;
34
+ /**
35
+ * Secrets referenced by this scenario, resolved server-side before the run.
36
+ * Scoped per scenario — a scenario never sees another scenario's secrets.
37
+ */
38
+ secrets?: Record<string, any>;
33
39
  options?: {
34
40
  printApiLogs?: boolean;
35
41
  };
@@ -1,4 +1,5 @@
1
1
  import type { TestContext } from "../contracts/runtime-context.types";
2
+ import { parsePathSegments } from "./path-segments";
2
3
 
3
4
  const TOKEN_REGEX = /\$\{([^}]+)\}/g;
4
5
 
@@ -13,74 +14,6 @@ function getByPath(obj: Record<string, any>, path: string): unknown {
13
14
  return current;
14
15
  }
15
16
 
16
- function parsePathSegments(path: string): string[] {
17
- const segments: string[] = [];
18
- let buffer = "";
19
- let i = 0;
20
-
21
- const pushBuffer = () => {
22
- if (buffer) {
23
- segments.push(buffer);
24
- buffer = "";
25
- }
26
- };
27
-
28
- while (i < path.length) {
29
- const ch = path[i];
30
- if (ch === ".") {
31
- pushBuffer();
32
- i += 1;
33
- continue;
34
- }
35
- if (ch === "[") {
36
- pushBuffer();
37
- i += 1;
38
- if (i >= path.length) break;
39
- let quote = "";
40
- if (path[i] === '"' || path[i] === "'") {
41
- quote = path[i];
42
- i += 1;
43
- }
44
- let value = "";
45
- while (i < path.length) {
46
- const c = path[i];
47
- if (quote) {
48
- if (c === "\\" && i + 1 < path.length) {
49
- value += path[i + 1];
50
- i += 2;
51
- continue;
52
- }
53
- if (c === quote) {
54
- i += 1;
55
- break;
56
- }
57
- value += c;
58
- i += 1;
59
- continue;
60
- }
61
- if (c === "]") break;
62
- value += c;
63
- i += 1;
64
- }
65
- while (i < path.length && path[i] !== "]") {
66
- i += 1;
67
- }
68
- if (i < path.length && path[i] === "]") {
69
- i += 1;
70
- }
71
- if (value) {
72
- segments.push(value);
73
- }
74
- continue;
75
- }
76
- buffer += ch;
77
- i += 1;
78
- }
79
-
80
- pushBuffer();
81
- return segments;
82
- }
83
-
84
17
  function getByPathWithBrackets(obj: Record<string, any>, path: string): unknown {
85
18
  if (!path) return undefined;
86
19
  const parts = parsePathSegments(path);
@@ -94,13 +27,50 @@ function getByPathWithBrackets(obj: Record<string, any>, path: string): unknown
94
27
 
95
28
  type TokenResolution = { value: unknown; raw: boolean };
96
29
 
30
+ const SECRET_PREFIX = "secret:";
31
+
32
+ /**
33
+ * Collects the secret keys referenced anywhere inside `value` — step args, headers,
34
+ * bodies — without depending on its shape. Used by the runner to resolve exactly the
35
+ * secrets a scenario needs, rather than loading the whole store into a run.
36
+ *
37
+ * Shares TOKEN_REGEX with the resolver deliberately: a second hand-written matcher is
38
+ * how the two drift, and drift means a scenario silently losing a secret it referenced.
39
+ */
40
+ export function collectSecretKeys(value: unknown): string[] {
41
+ const keys = new Set<string>();
42
+ for (const match of JSON.stringify(value ?? null).matchAll(TOKEN_REGEX)) {
43
+ const token = match[1];
44
+ if (token.startsWith(SECRET_PREFIX)) {
45
+ const name = token.slice(SECRET_PREFIX.length);
46
+ if (name) keys.add(name);
47
+ }
48
+ }
49
+ return [...keys];
50
+ }
51
+
97
52
  function resolveToken(token: string, ctx: TestContext): TokenResolution {
53
+ if (token.startsWith(SECRET_PREFIX)) {
54
+ const name = token.slice(SECRET_PREFIX.length);
55
+ if (!name) {
56
+ throw new Error('Invalid interpolation token: "secret:"');
57
+ }
58
+ // Resolved server-side into ctx.secrets before the run; never caller-supplied.
59
+ const value = ctx.secrets?.[name];
60
+ if (value === undefined) {
61
+ throw new Error(`Missing secret for token: "${token}"`);
62
+ }
63
+ // Secrets carry a declared valueType, so a json/number/boolean secret used as an
64
+ // exact token returns typed rather than stringified. Strings behave like env:.
65
+ return { value, raw: typeof value !== "string" };
66
+ }
67
+
98
68
  if (token.startsWith("env:")) {
99
69
  const name = token.slice("env:".length);
100
70
  if (!name) {
101
71
  throw new Error('Invalid interpolation token: "env:"');
102
72
  }
103
- const value = process.env[name];
73
+ const value = ctx.env?.[name] ?? process.env[name];
104
74
  if (value === undefined) {
105
75
  throw new Error(`Missing env var for token: "${token}"`);
106
76
  }
@@ -0,0 +1,133 @@
1
+ // Purpose: lock down lookup-path parsing and token interpolation — the behaviours that decide
2
+ // whether a scenario file's `${res:...}` / `${data:...}` references resolve or silently vanish.
3
+
4
+ import type { TestContext } from "../contracts/runtime-context.types";
5
+ import { interpolateDeep, interpolateString } from "./interpolation";
6
+ import { parsePathSegments } from "./path-segments";
7
+ import { SimpleResourceStore } from "./resource-store";
8
+
9
+ function makeCtx(overrides: Partial<TestContext> = {}): TestContext {
10
+ return {
11
+ scenarioId: "spec",
12
+ scenarioType: "api",
13
+ params: {},
14
+ resources: new SimpleResourceStore(),
15
+ reporter: { onScenarioStart: () => {}, onStep: () => {}, onScenarioEnd: () => {} } as any,
16
+ ...overrides,
17
+ } as TestContext;
18
+ }
19
+
20
+ describe("parsePathSegments", () => {
21
+ it("treats dot and bracket notation as equivalent", () => {
22
+ expect(parsePathSegments("a.b.0.c")).toEqual(["a", "b", "0", "c"]);
23
+ expect(parsePathSegments("a.b[0].c")).toEqual(["a", "b", "0", "c"]);
24
+ expect(parsePathSegments('a["b"].c')).toEqual(["a", "b", "c"]);
25
+ });
26
+
27
+ it("keeps quoted keys containing spaces and punctuation intact", () => {
28
+ expect(parsePathSegments('book["Clean Code"].title')).toEqual(["book", "Clean Code", "title"]);
29
+ expect(parsePathSegments("book['a.b'].title")).toEqual(["book", "a.b", "title"]);
30
+ });
31
+
32
+ it("returns no segments for an empty path", () => {
33
+ expect(parsePathSegments("")).toEqual([]);
34
+ });
35
+ });
36
+
37
+ describe("SimpleResourceStore", () => {
38
+ function seeded() {
39
+ const store = new SimpleResourceStore();
40
+ store.set("createBook", {
41
+ status: 201,
42
+ bodyText: '{"data":{"title":"Clean Code"}}',
43
+ bodyJson: { data: { title: "Clean Code" }, result: [{ id: 7 }, { id: 8 }] },
44
+ });
45
+ return store;
46
+ }
47
+
48
+ it("resolves dot paths", () => {
49
+ expect(seeded().get("createBook.bodyJson.data.title")).toBe("Clean Code");
50
+ expect(seeded().get("createBook.status")).toBe(201);
51
+ });
52
+
53
+ it("resolves array elements by numeric segment", () => {
54
+ expect(seeded().get("createBook.bodyJson.result.0.id")).toBe(7);
55
+ });
56
+
57
+ it("resolves array elements by bracket index, equivalently", () => {
58
+ const store = seeded();
59
+ expect(store.get("createBook.bodyJson.result[0].id")).toBe(7);
60
+ expect(store.get("createBook.bodyJson.result[1].id")).toBe(8);
61
+ // the two spellings must agree — this asymmetry with ${data:...} was the original bug
62
+ expect(store.get("createBook.bodyJson.result[0].id")).toBe(
63
+ store.get("createBook.bodyJson.result.0.id"),
64
+ );
65
+ });
66
+
67
+ it("returns undefined for a missing path rather than throwing", () => {
68
+ expect(seeded().get("createBook.bodyJson.nope.deeper")).toBeUndefined();
69
+ expect(seeded().get("neverSaved")).toBeUndefined();
70
+ });
71
+
72
+ it("round-trips: anything readable by bracket path is also writable by one", () => {
73
+ const store = new SimpleResourceStore();
74
+ store.set('a["b c"].d', 42);
75
+ expect(store.get('a["b c"].d')).toBe(42);
76
+ expect(store.get("a.b c.d")).toBe(42);
77
+ });
78
+
79
+ it("has() follows get()", () => {
80
+ const store = seeded();
81
+ expect(store.has("createBook.bodyJson.result[0].id")).toBe(true);
82
+ expect(store.has("createBook.bodyJson.result[9].id")).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("interpolation", () => {
87
+ it("stringifies non-string ${res:...} values", () => {
88
+ const ctx = makeCtx();
89
+ ctx.resources.set("r", { status: 200, bodyJson: { id: 1 } });
90
+ // the whole object flattens to text — which is why assert.httpStatus `from` and
91
+ // assert.jsonPath `from` can never receive a live response object
92
+ expect(interpolateDeep("${res:r}", ctx)).toBe('{"status":200,"bodyJson":{"id":1}}');
93
+ // a scalar leaf survives intact, because the store walks the object before flattening
94
+ expect(interpolateDeep("${res:r.bodyJson.id}", ctx)).toBe("1");
95
+ });
96
+
97
+ it("resolves ${res:...} bracket paths", () => {
98
+ const ctx = makeCtx();
99
+ ctx.resources.set("r", { bodyJson: { result: [{ id: 7 }] } });
100
+ expect(interpolateDeep("${res:r.bodyJson.result[0].id}", ctx)).toBe("7");
101
+ expect(interpolateDeep("${res:r.bodyJson.result.0.id}", ctx)).toBe("7");
102
+ });
103
+
104
+ it("preserves the raw record for ${data:..._rec} only when it is the entire value", () => {
105
+ const ctx = makeCtx({ testData: { book: { CleanCode: { title: "Clean Code", pages: 464 } } } });
106
+ expect(interpolateDeep('${data:book["CleanCode"]._rec}', ctx)).toEqual({
107
+ title: "Clean Code",
108
+ pages: 464,
109
+ });
110
+ // embedded in a larger string it has to flatten, since the result must stay a string
111
+ expect(interpolateDeep('x ${data:book["CleanCode"]._rec}', ctx)).toBe(
112
+ 'x {"title":"Clean Code","pages":464}',
113
+ );
114
+ expect(interpolateDeep('${data:book["CleanCode"].title}', ctx)).toBe("Clean Code");
115
+ });
116
+
117
+ it("resolves ${secret:...} and keeps it out of nothing else", () => {
118
+ const ctx = makeCtx({ secrets: { "test-username": "alice" } });
119
+ expect(interpolateDeep("${secret:test-username}", ctx)).toBe("alice");
120
+ expect(() => interpolateString("${secret:absent}", ctx)).toThrow(/Missing secret/);
121
+ });
122
+
123
+ it("throws a named error for an unsaved resource rather than yielding undefined", () => {
124
+ expect(() => interpolateString("${res:neverSaved.id}", makeCtx())).toThrow(/Missing resource/);
125
+ });
126
+
127
+ it("recurses through objects and arrays", () => {
128
+ const ctx = makeCtx({ params: { n: "1" } });
129
+ expect(interpolateDeep({ a: ["${params.n}", { b: "${params.n}" }] }, ctx)).toEqual({
130
+ a: ["1", { b: "1" }],
131
+ });
132
+ });
133
+ });
@@ -0,0 +1,81 @@
1
+ // Purpose: shared lookup-path parsing for interpolation tokens and the resource store.
2
+
3
+ /**
4
+ * Split a lookup path into segments, accepting dot and bracket notation interchangeably:
5
+ * `a.b.0.c`, `a.b[0].c` and `a["b"].c` all yield the same segments.
6
+ *
7
+ * Both `${data:...}` and `${res:...}` resolve through this, so the two token families share one
8
+ * path syntax. Previously only `${data:...}` parsed brackets while the resource store split on
9
+ * "." alone, which meant `${res:r.bodyJson.result[0].id}` silently resolved to nothing while the
10
+ * `${data:...}` form right next to it worked — the asymmetry was a standing authoring trap.
11
+ *
12
+ * Bracket contents may be quoted (`["Clean Code"]`) for keys containing spaces or punctuation;
13
+ * backslash escapes inside quotes are honoured. Empty brackets contribute no segment.
14
+ */
15
+ export function parsePathSegments(path: string): string[] {
16
+ const segments: string[] = [];
17
+ let buffer = "";
18
+ let i = 0;
19
+
20
+ const pushBuffer = () => {
21
+ if (buffer) {
22
+ segments.push(buffer);
23
+ buffer = "";
24
+ }
25
+ };
26
+
27
+ while (i < path.length) {
28
+ const ch = path[i];
29
+ if (ch === ".") {
30
+ pushBuffer();
31
+ i += 1;
32
+ continue;
33
+ }
34
+ if (ch === "[") {
35
+ pushBuffer();
36
+ i += 1;
37
+ if (i >= path.length) break;
38
+ let quote = "";
39
+ if (path[i] === '"' || path[i] === "'") {
40
+ quote = path[i];
41
+ i += 1;
42
+ }
43
+ let value = "";
44
+ while (i < path.length) {
45
+ const c = path[i];
46
+ if (quote) {
47
+ if (c === "\\" && i + 1 < path.length) {
48
+ value += path[i + 1];
49
+ i += 2;
50
+ continue;
51
+ }
52
+ if (c === quote) {
53
+ i += 1;
54
+ break;
55
+ }
56
+ value += c;
57
+ i += 1;
58
+ continue;
59
+ }
60
+ if (c === "]") break;
61
+ value += c;
62
+ i += 1;
63
+ }
64
+ while (i < path.length && path[i] !== "]") {
65
+ i += 1;
66
+ }
67
+ if (i < path.length && path[i] === "]") {
68
+ i += 1;
69
+ }
70
+ if (value) {
71
+ segments.push(value);
72
+ }
73
+ continue;
74
+ }
75
+ buffer += ch;
76
+ i += 1;
77
+ }
78
+
79
+ pushBuffer();
80
+ return segments;
81
+ }