flowstride 1.0.20-rc.6 → 1.0.20

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.
@@ -52,6 +52,21 @@ class WebAdapter {
52
52
  page = null;
53
53
  discoveredPages = [];
54
54
  dialogHandler = null;
55
+ pendingSessionStorageRestore = null;
56
+ async clearSessionStorageRestore() {
57
+ const pending = this.pendingSessionStorageRestore;
58
+ this.pendingSessionStorageRestore = null;
59
+ if (!pending)
60
+ return;
61
+ pending.page.off('framenavigated', pending.onNavigation);
62
+ try {
63
+ await pending.script.dispose();
64
+ }
65
+ catch (error) {
66
+ if (!pending.page.isClosed())
67
+ throw error;
68
+ }
69
+ }
55
70
  constructor(config) {
56
71
  this.config = config;
57
72
  }
@@ -61,6 +76,7 @@ class WebAdapter {
61
76
  return this.page;
62
77
  }
63
78
  async initialise(audioInjectionPath) {
79
+ await this.clearSessionStorageRestore();
64
80
  const launchArgs = this.config.headless
65
81
  ? [
66
82
  "--use-fake-ui-for-media-stream",
@@ -183,9 +199,26 @@ class WebAdapter {
183
199
  await this.page.bringToFront();
184
200
  await this.page.waitForLoadState("domcontentloaded").catch(() => { });
185
201
  }
186
- async injectStorage(cookies, localStorage, fallbackDomain) {
202
+ async injectStorage(cookies, localStorage, fallbackDomain, sessionStorage, storageOrigin) {
187
203
  if (!this.context || !this.page)
188
204
  return;
205
+ await this.clearSessionStorageRestore();
206
+ const page = this.page;
207
+ const originOf = (value) => {
208
+ try {
209
+ const url = new URL(value || '');
210
+ return ['http:', 'https:'].includes(url.protocol) ? url.origin : undefined;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ };
216
+ const hasSessionStorage = !!sessionStorage && Object.keys(sessionStorage).length > 0;
217
+ const origin = storageOrigin !== undefined ? originOf(storageOrigin)
218
+ : originOf(page.url()) || originOf(this.config.baseUrl);
219
+ if (hasSessionStorage && !origin) {
220
+ throw new Error('Session storage restoration requires a saved HTTP(S) origin, an open application page, or baseUrl.');
221
+ }
189
222
  if (cookies && cookies.length > 0) {
190
223
  const formattedCookies = [];
191
224
  for (const cookie of cookies) {
@@ -232,6 +265,32 @@ class WebAdapter {
232
265
  }, localStorage);
233
266
  }
234
267
  }
268
+ if (hasSessionStorage) {
269
+ const restore = (data) => {
270
+ if (window !== window.top || window.location.origin !== data.origin)
271
+ return;
272
+ for (const [key, value] of Object.entries(data.values))
273
+ window.sessionStorage.setItem(key, value);
274
+ };
275
+ const data = { origin: origin, values: sessionStorage };
276
+ if (originOf(page.url()) === origin) {
277
+ await page.evaluate(restore, data);
278
+ }
279
+ else {
280
+ // Seed before application scripts run; remove the seed after the first
281
+ // matching document so reloads preserve later application changes.
282
+ const script = await page.addInitScript(restore, data);
283
+ const onNavigation = (frame) => {
284
+ if (frame !== page.mainFrame() || originOf(frame.url()) !== origin)
285
+ return;
286
+ void this.clearSessionStorageRestore().catch(() => {
287
+ console.warn('[Session]: Could not remove the completed session storage initializer.');
288
+ });
289
+ };
290
+ this.pendingSessionStorageRestore = { page, script, onNavigation };
291
+ page.on('framenavigated', onNavigation);
292
+ }
293
+ }
235
294
  }
236
295
  async getState() {
237
296
  const cookies = (await this.context?.cookies()) || [];
@@ -250,7 +309,14 @@ class WebAdapter {
250
309
  console.warn("Warning: Could not access storage. Ensure page is loaded.");
251
310
  }
252
311
  }
253
- return { cookies, localStorage, sessionStorage };
312
+ let storageOrigin;
313
+ try {
314
+ const url = new URL(this.page?.url() || '');
315
+ if (['http:', 'https:'].includes(url.protocol))
316
+ storageOrigin = url.origin;
317
+ }
318
+ catch { }
319
+ return { cookies, localStorage, sessionStorage, storageOrigin };
254
320
  }
255
321
  getCurrentUrl() {
256
322
  return this.page ? this.page.url() : "about:blank";
@@ -1196,6 +1262,7 @@ class WebAdapter {
1196
1262
  }
1197
1263
  async shutdown(videoTargetPath) {
1198
1264
  try {
1265
+ await this.clearSessionStorageRestore();
1199
1266
  const video = this.page?.video();
1200
1267
  if (this.context) {
1201
1268
  await this.context.close();
package/dist/cli/index.js CHANGED
@@ -63,7 +63,7 @@ let API_BASE = (0, cloud_1.cloudApiBase)(process.env);
63
63
  program
64
64
  .name("flowstride")
65
65
  .description("Flowstride: The Enterprise Flow-First Automation CLI")
66
- .version("1.0.20-rc.6");
66
+ .version("1.0.20");
67
67
  program
68
68
  .command("init")
69
69
  .description("Initialise a new Flowstride workspace in the current directory")
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.FlowWorker = void 0;
40
+ const deadline_1 = require("../ci/deadline");
41
+ const replayRequest_1 = require("../utils/replayRequest");
40
42
  const projectPaths_1 = require("../utils/projectPaths");
41
43
  const redaction_1 = require("../security/redaction");
42
44
  const fs = __importStar(require("fs"));
@@ -282,6 +284,7 @@ class FlowWorker extends events_1.EventEmitter {
282
284
  url: cleanUrl,
283
285
  requestHeaders: formattedHeaders,
284
286
  requestBody: parsedReqBody,
287
+ rawReplayRequest: { method: method.toUpperCase(), url: cleanUrl, headers: formattedHeaders, body: rawBodyString },
285
288
  responseStatus: state.status,
286
289
  responseHeaders: state.headers,
287
290
  responseBody: state.body,
@@ -304,33 +307,11 @@ class FlowWorker extends events_1.EventEmitter {
304
307
  });
305
308
  }
306
309
  async handleReplayRequest(payload) {
310
+ const requestId = typeof payload?.requestId === 'string' ? payload.requestId : undefined;
307
311
  try {
308
- let parsedHeaders = undefined;
309
- let parsedBody = undefined;
310
- try {
311
- if (payload.headers)
312
- parsedHeaders = JSON.parse(payload.headers);
313
- }
314
- catch (e) { }
315
- try {
316
- if (payload.body)
317
- parsedBody = JSON.parse(payload.body);
318
- }
319
- catch (e) {
320
- parsedBody = payload.body;
321
- }
322
- const fetchOptions = {
323
- method: payload.method,
324
- headers: parsedHeaders,
325
- };
326
- if (parsedBody && payload.method !== "GET" && payload.method !== "HEAD") {
327
- fetchOptions.body =
328
- typeof parsedBody === "string"
329
- ? parsedBody
330
- : JSON.stringify(parsedBody);
331
- }
332
- const response = await fetch(payload.url, fetchOptions);
333
- const status = response.status;
312
+ const { url, options } = (0, replayRequest_1.buildReplayRequest)(payload);
313
+ options.signal = AbortSignal.timeout(this.config.timeout || 30000);
314
+ const response = await fetch(url, options);
334
315
  const text = await response.text();
335
316
  let responseBody;
336
317
  try {
@@ -339,17 +320,18 @@ class FlowWorker extends events_1.EventEmitter {
339
320
  catch {
340
321
  responseBody = text;
341
322
  }
342
- this.broadcast("REPLAY_RES", {
343
- status,
344
- responseBody,
323
+ this.broadcast('REPLAY_RES', {
324
+ status: response.status, responseBody, error: null, requestId,
345
325
  fileName: this.currentFileName,
346
326
  });
347
327
  }
348
328
  catch (error) {
349
- this.broadcast("REPLAY_RES", {
350
- status: 500,
351
- responseBody: { error: error.message },
352
- fileName: this.currentFileName,
329
+ const code = error instanceof replayRequest_1.ReplayRequestError ? 'REPLAY_INVALID_REQUEST'
330
+ : error?.name === 'TimeoutError' ? 'REPLAY_TIMEOUT' : 'REPLAY_NETWORK_ERROR';
331
+ const message = (0, redaction_1.redactText)(String(error?.message || 'Replay request failed'), [this.config.machineToken || '']);
332
+ this.broadcast('REPLAY_RES', {
333
+ status: null, error: code, requestId,
334
+ responseBody: { error: message, code }, fileName: this.currentFileName,
353
335
  });
354
336
  }
355
337
  }
@@ -872,6 +854,7 @@ class FlowWorker extends events_1.EventEmitter {
872
854
  localStorage: stateToSave.localStorage || {},
873
855
  sessionStorage: stateToSave.sessionStorage || {},
874
856
  fallbackDomain,
857
+ storageOrigin: stateToSave.storageOrigin,
875
858
  };
876
859
  if (payload.isGlobal) {
877
860
  FlowWorker.globalBrowserSessions[payload.name] = hybridState;
@@ -920,7 +903,7 @@ class FlowWorker extends events_1.EventEmitter {
920
903
  }
921
904
  if (!loadedState)
922
905
  throw new Error(`State Error: The session "${payload.name}" does not exist in memory or on disk.`);
923
- await this.web.injectStorage(loadedState.cookies, loadedState.localStorage, loadedState.fallbackDomain);
906
+ await this.web.injectStorage(loadedState.cookies, loadedState.localStorage, loadedState.fallbackDomain, loadedState.sessionStorage, loadedState.storageOrigin);
924
907
  }
925
908
  break;
926
909
  case "open":
@@ -1170,6 +1153,27 @@ class FlowWorker extends events_1.EventEmitter {
1170
1153
  await this.web.expectText(payload.selector, payload.fuzzy, payload.elementType);
1171
1154
  else if (expectType === "value")
1172
1155
  await this.web.expectValue(payload.selector, payload.text, payload.elementType);
1156
+ else if (expectType === "attribute") {
1157
+ const deadline = Date.now() + this.config.timeout;
1158
+ let actual = null;
1159
+ const failure = () => `Attribute assertion failed: ${payload.attribute} on ${payload.selector}. Expected ${payload.fuzzy ? 'to contain ' : ''}${JSON.stringify(payload.text)}, received ${JSON.stringify(actual)}.`;
1160
+ while (true) {
1161
+ const remaining = deadline - Date.now();
1162
+ if (remaining <= 0)
1163
+ throw new Error(failure());
1164
+ try {
1165
+ actual = await (0, deadline_1.withDeadline)(this.web.getAttribute(payload.selector, payload.attribute, payload.elementType), remaining);
1166
+ }
1167
+ catch (error) {
1168
+ throw new Error(`${failure()}\n${error instanceof Error ? error.message : String(error)}`, { cause: error });
1169
+ }
1170
+ if (actual !== null && (payload.fuzzy ? actual.includes(payload.text) : actual === payload.text))
1171
+ break;
1172
+ if (Date.now() >= deadline)
1173
+ throw new Error(failure());
1174
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(50, deadline - Date.now()))));
1175
+ }
1176
+ }
1173
1177
  else if (expectType === "transcript")
1174
1178
  await this.web.expectTranscript(payload.text);
1175
1179
  break;
@@ -1211,6 +1215,7 @@ class FlowWorker extends events_1.EventEmitter {
1211
1215
  url: request.url(),
1212
1216
  requestHeaders: requestHeaders,
1213
1217
  requestBody: requestBody,
1218
+ rawReplayRequest: { method: request.method(), url: request.url(), headers: requestHeaders, body: rawPostData },
1214
1219
  responseStatus: response.status(),
1215
1220
  responseHeaders: response.headers(),
1216
1221
  responseBody: responseBody,
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.redactText = redactText;
4
4
  exports.redactPayload = redactPayload;
5
5
  exports.redactWorkerPayload = redactWorkerPayload;
6
- const sensitive = /password|passcode|secret|token|authorization|cookie|api.?key|master.?key|otp|email|curlString|strippedDom/i;
6
+ const sensitive = /password|passcode|secret|token|authorization|cookie|api.?key|master.?key|otp|email|curlString|rawReplayRequest|strippedDom/i;
7
7
  function redactText(input, secrets = []) {
8
8
  let value = String(input);
9
9
  for (const secret of secrets.filter(s => typeof s === 'string' && s.length >= 4).sort((a, b) => b.length - a.length))
@@ -44,6 +44,8 @@ function redactWorkerPayload(type, payload, secrets, localDebug = false) {
44
44
  return result;
45
45
  if (type === 'LOG_EVENT' && ['REST', 'GRAPHQL'].includes(payload.type) && typeof payload.details?.curlString === 'string') {
46
46
  result.details.rawCurlString = payload.details.curlString;
47
+ if (payload.details.rawReplayRequest)
48
+ result.details.rawReplayRequest = payload.details.rawReplayRequest;
47
49
  }
48
50
  return result;
49
51
  }
@@ -35,7 +35,7 @@ class SessionManager {
35
35
  data.lastUsedAt = Date.now();
36
36
  this.vault.save(`session_${name}`, data);
37
37
  this.activeSession = data;
38
- await this.web.injectStorage(data.cookies, data.localStorage);
38
+ await this.web.injectStorage(data.cookies, data.localStorage, undefined, data.sessionStorage, data.storageOrigin);
39
39
  const definition = this.definitions.get(name);
40
40
  if (definition?.inject?.api) {
41
41
  const { headerName, template } = definition.inject.api;
@@ -64,6 +64,8 @@ class SessionManager {
64
64
  tokens: this.activeSession?.tokens || {},
65
65
  cookies: state.cookies,
66
66
  localStorage: state.localStorage,
67
+ sessionStorage: state.sessionStorage,
68
+ storageOrigin: state.storageOrigin,
67
69
  createdAt: Date.now(),
68
70
  lastUsedAt: Date.now(),
69
71
  persist,
@@ -53,6 +53,8 @@ export interface SessionData {
53
53
  tokens: Record<string, any>;
54
54
  cookies?: any[];
55
55
  localStorage?: Record<string, string>;
56
+ sessionStorage?: Record<string, string>;
57
+ storageOrigin?: string;
56
58
  createdAt: number;
57
59
  lastUsedAt: number;
58
60
  persist: boolean;
@@ -0,0 +1,8 @@
1
+ export declare class ReplayRequestError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /** Browser protocol metadata is not a portable set of Fetch request headers. */
5
+ export declare function buildReplayRequest(payload: any): {
6
+ url: string;
7
+ options: RequestInit;
8
+ };
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReplayRequestError = void 0;
4
+ exports.buildReplayRequest = buildReplayRequest;
5
+ class ReplayRequestError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'ReplayRequestError';
9
+ }
10
+ }
11
+ exports.ReplayRequestError = ReplayRequestError;
12
+ /** Browser protocol metadata is not a portable set of Fetch request headers. */
13
+ function buildReplayRequest(payload) {
14
+ let url;
15
+ try {
16
+ url = new URL(payload?.url);
17
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
18
+ throw new Error();
19
+ }
20
+ catch {
21
+ throw new ReplayRequestError('Replay URL must use HTTP or HTTPS without embedded credentials.');
22
+ }
23
+ const method = typeof payload?.method === 'string' ? payload.method.toUpperCase() : 'GET';
24
+ if (!/^[!#$%&'*+.^_`|~0-9A-Z-]+$/.test(method) || ['CONNECT', 'TRACE', 'TRACK'].includes(method)) {
25
+ throw new ReplayRequestError('Replay method is not supported.');
26
+ }
27
+ let supplied = payload?.headers;
28
+ if (typeof supplied === 'string') {
29
+ try {
30
+ supplied = supplied.trim() ? JSON.parse(supplied) : {};
31
+ }
32
+ catch {
33
+ throw new ReplayRequestError('Replay headers must be a valid JSON object.');
34
+ }
35
+ }
36
+ if (supplied === undefined)
37
+ supplied = {};
38
+ if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied)) {
39
+ throw new ReplayRequestError('Replay headers must be a JSON object of string values.');
40
+ }
41
+ // Fetch owns the destination, framing and supported compression. Recompute
42
+ // Content-Length after edits and remove all Connection-nominated fields too.
43
+ const excluded = new Set([
44
+ 'host', 'content-length', 'accept-encoding', 'connection', 'keep-alive',
45
+ 'proxy-connection', 'proxy-authenticate', 'proxy-authorization', 'te',
46
+ 'trailer', 'transfer-encoding', 'upgrade', 'expect',
47
+ ]);
48
+ for (const [name, value] of Object.entries(supplied)) {
49
+ if (name.toLowerCase() === 'connection' && typeof value === 'string') {
50
+ for (const field of value.split(','))
51
+ excluded.add(field.trim().toLowerCase());
52
+ }
53
+ }
54
+ const headers = new Headers();
55
+ for (const [name, value] of Object.entries(supplied)) {
56
+ if (name.startsWith(':') || excluded.has(name.toLowerCase()))
57
+ continue;
58
+ if (typeof value !== 'string')
59
+ throw new ReplayRequestError('Replay header values must be strings.');
60
+ try {
61
+ headers.append(name, value);
62
+ }
63
+ catch {
64
+ throw new ReplayRequestError('Replay headers contain an invalid name or value.');
65
+ }
66
+ }
67
+ const options = { method, headers };
68
+ if (!['GET', 'HEAD'].includes(method) && payload?.body !== undefined && payload.body !== null) {
69
+ try {
70
+ options.body = typeof payload.body === 'string' ? payload.body : JSON.stringify(payload.body);
71
+ }
72
+ catch {
73
+ throw new ReplayRequestError('Replay body cannot be serialized.');
74
+ }
75
+ }
76
+ return { url: url.href, options };
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowstride",
3
- "version": "1.0.20-rc.6",
3
+ "version": "1.0.20",
4
4
  "description": "An opensource flowfirst test automation framework",
5
5
  "workspaces": [
6
6
  "packages/flowstride_report"
@@ -27,8 +27,10 @@
27
27
  "verify:candidate": "node scripts/verify-candidate.cjs",
28
28
  "test:dashboard:unit": "node --test tests/dashboard/store.test.cjs",
29
29
  "test:dashboard": "node --test --test-concurrency=1 tests/dashboard/*.test.cjs",
30
- "test:unit": "node --test tests/native/core.test.cjs tests/native/combined.test.cjs tests/native/curl.test.cjs tests/native/upload.test.cjs tests/dashboard/store.test.cjs",
31
- "test:security": "node --test tests/manual-security.test.cjs tests/native/curl.test.cjs tests/native/combined.test.cjs"
30
+ "test:unit": "node --test tests/native/core.test.cjs tests/native/combined.test.cjs tests/native/curl.test.cjs tests/native/upload.test.cjs tests/dashboard/store.test.cjs tests/native/replay.test.cjs tests/dashboard/replay.test.cjs tests/native/assertions-sessions.test.cjs",
31
+ "test:security": "node --test tests/manual-security.test.cjs tests/native/curl.test.cjs tests/native/combined.test.cjs tests/native/replay.test.cjs tests/native/assertions-sessions.test.cjs",
32
+ "test:replay": "node --test tests/native/replay.test.cjs tests/dashboard/replay.test.cjs tests/dashboard/curl.browser.test.cjs",
33
+ "test:assertions-sessions": "node --test --test-concurrency=1 tests/native/assertions-sessions*.test.cjs"
32
34
  },
33
35
  "keywords": [
34
36
  "automation",
@@ -69,6 +71,6 @@
69
71
  "@rolldown/binding-darwin-arm64": "^0.15.1"
70
72
  },
71
73
  "publishConfig": {
72
- "tag": "next"
74
+ "tag": "latest"
73
75
  }
74
76
  }