flowstride 1.0.20 → 1.0.22

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 (99) hide show
  1. package/NATIVE_BROWSER.md +381 -0
  2. package/NATIVE_CI.md +17 -5
  3. package/NATIVE_WORKFLOWS.md +44 -0
  4. package/README.md +11 -4
  5. package/dist/adapters/api/undici.d.ts +11 -1
  6. package/dist/adapters/api/undici.js +28 -3
  7. package/dist/adapters/web/playwright.d.ts +22 -2
  8. package/dist/adapters/web/playwright.js +254 -107
  9. package/dist/ci/config.js +14 -1
  10. package/dist/ci/distribution.d.ts +38 -0
  11. package/dist/ci/distribution.js +134 -0
  12. package/dist/ci/init.d.ts +4 -1
  13. package/dist/ci/init.js +13 -2
  14. package/dist/ci/runtime.js +33 -6
  15. package/dist/cli/commands/run.js +17 -4
  16. package/dist/cli/index.js +24 -5
  17. package/dist/cli/manual_server/workspace_server.js +90 -74
  18. package/dist/manual/security.d.ts +6 -6
  19. package/dist/native/accessibility.d.ts +4 -0
  20. package/dist/native/accessibility.js +84 -0
  21. package/dist/native/assertions.d.ts +3 -0
  22. package/dist/native/assertions.js +223 -0
  23. package/dist/native/compatibility.d.ts +3 -0
  24. package/dist/native/compatibility.js +16 -0
  25. package/dist/native/components.d.ts +12 -0
  26. package/dist/native/components.js +122 -0
  27. package/dist/native/dashboard.d.ts +7 -0
  28. package/dist/native/dashboard.js +32 -0
  29. package/dist/native/deadline.d.ts +2 -0
  30. package/dist/native/deadline.js +13 -0
  31. package/dist/native/events.d.ts +35 -0
  32. package/dist/native/events.js +308 -0
  33. package/dist/native/evidence.d.ts +3 -0
  34. package/dist/native/evidence.js +31 -0
  35. package/dist/native/functions.d.ts +10 -0
  36. package/dist/native/functions.js +131 -0
  37. package/dist/native/runtime.d.ts +17 -0
  38. package/dist/native/runtime.js +160 -0
  39. package/dist/native/validation.d.ts +107 -0
  40. package/dist/native/validation.js +84 -0
  41. package/dist/parser/assertion_contract.d.ts +12 -0
  42. package/dist/parser/assertion_contract.js +56 -0
  43. package/dist/parser/lexer.js +15 -4
  44. package/dist/parser/parser.js +15 -7
  45. package/dist/reporting/assertion_result.d.ts +36 -0
  46. package/dist/reporting/assertion_result.js +104 -0
  47. package/dist/reporting/pdf_generator.js +25 -20
  48. package/dist/reporting/runtime_failure.d.ts +10 -0
  49. package/dist/reporting/runtime_failure.js +64 -0
  50. package/dist/reporting/step_status.d.ts +10 -0
  51. package/dist/reporting/step_status.js +44 -0
  52. package/dist/runner/flow_orchestrator.d.ts +5 -0
  53. package/dist/runner/flow_orchestrator.js +30 -8
  54. package/dist/runner/flow_worker.d.ts +15 -1
  55. package/dist/runner/flow_worker.js +219 -42
  56. package/dist/runner/plugin_manager.js +3 -0
  57. package/dist/runner/temporary_store.d.ts +1 -0
  58. package/dist/runner/temporary_store.js +3 -0
  59. package/dist/sdk/api_proxy.d.ts +3 -0
  60. package/dist/sdk/api_proxy.js +16 -0
  61. package/dist/security/localWorkspace.d.ts +2 -0
  62. package/dist/security/localWorkspace.js +17 -0
  63. package/dist/security/redaction.js +1 -1
  64. package/dist/types/index.d.ts +13 -0
  65. package/examples/native-workflows/README.md +144 -0
  66. package/examples/native-workflows/api-and-ui/README.md +80 -0
  67. package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
  68. package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
  69. package/examples/native-workflows/api-ui-otp/README.md +140 -0
  70. package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
  71. package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
  72. package/examples/native-workflows/audio-to-text/README.md +131 -0
  73. package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
  74. package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
  75. package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
  76. package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
  77. package/examples/native-workflows/iframes/README.md +116 -0
  78. package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
  79. package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
  80. package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
  81. package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
  82. package/examples/native-workflows/manual-testing/README.md +85 -0
  83. package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
  84. package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
  85. package/examples/native-workflows/shared/server.mjs +80 -0
  86. package/examples/native-workflows/tables-and-cells/README.md +118 -0
  87. package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
  88. package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
  89. package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
  90. package/examples/native-workflows/variables-and-reuse/README.md +140 -0
  91. package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
  92. package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
  93. package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
  94. package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
  95. package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
  96. package/package.json +23 -9
  97. package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
  98. package/src/ui/dist/index.html +1 -1
  99. package/src/ui/dist/assets/index-B7MuBTWm.js +0 -460
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseShard = parseShard;
7
+ exports.executionPlan = executionPlan;
8
+ exports.mergeResults = mergeResults;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_crypto_1 = require("node:crypto");
12
+ const projectPaths_1 = require("../utils/projectPaths");
13
+ function parseShard(value) {
14
+ if (value === undefined)
15
+ return { index: 1, total: 1 };
16
+ const match = /^(\d+)\/(\d+)$/.exec(value);
17
+ if (!match)
18
+ throw new Error('shard must be index/total, e.g. 1/4.');
19
+ const index = Number(match[1]), total = Number(match[2]);
20
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 1 || index > total || total > 256)
21
+ throw new Error('Shard index must be between 1 and total (maximum 256).');
22
+ return { index, total };
23
+ }
24
+ function executionPlan(target, config) {
25
+ const root = config?.projectRoot || process.cwd(), files = (0, projectPaths_1.discoverFlows)(target, root);
26
+ const flowRoot = node_fs_1.default.statSync(target).isDirectory() ? target : node_path_1.default.dirname(target);
27
+ const shard = parseShard(config?.shard);
28
+ const identity = (file) => (0, projectPaths_1.flowIdentity)(file, flowRoot);
29
+ const suiteHash = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(files.map(file => [identity(file), (0, node_crypto_1.createHash)('sha256').update(node_fs_1.default.readFileSync(file)).digest('hex')]))).digest('hex');
30
+ // Configuration affects compatibility, but only a hash leaves this process.
31
+ const configHash = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify([require('../../package.json').name, require('../../package.json').version, config?.browser || 'chromium', config?.device || 'desktop', config?.browserOptions || {}, config?.retries || 0, config?.baseUrl || '', config?.timeout || 30000, config?.headless, config?.browserErrors, config?.failOnFlaky, config?.recordVideo, config?.trace, config?.har])).digest('hex');
32
+ let history = {};
33
+ if (config?.shardHistory) {
34
+ history = JSON.parse(node_fs_1.default.readFileSync((0, projectPaths_1.projectPath)(root, config.shardHistory), 'utf8'));
35
+ if (!history || Array.isArray(history) || typeof history !== 'object' || Object.values(history).some(value => typeof value !== 'number' || !Number.isFinite(value) || value < 0))
36
+ throw new Error('Shard history must map file names to nonnegative durations.');
37
+ }
38
+ const setup = files.filter(file => file.endsWith('.first.flow'));
39
+ const standard = files.filter(file => !file.endsWith('.first.flow'));
40
+ const bins = Array.from({ length: shard.total }, () => []), weights = bins.map(() => 0);
41
+ for (const file of [...standard].sort((a, b) => (history[identity(b)] ?? 1) - (history[identity(a)] ?? 1) || identity(a).localeCompare(identity(b)))) {
42
+ const index = weights.indexOf(Math.min(...weights));
43
+ bins[index].push(file);
44
+ weights[index] += history[identity(file)] ?? 1;
45
+ }
46
+ const assigned = new Set([...setup, ...bins[shard.index - 1]]);
47
+ return { files: files.filter(file => assigned.has(file)), allFiles: files.map(identity), setupFiles: setup.map(identity), suiteHash, configHash, shard, flowRoot };
48
+ }
49
+ function mergeResults(summaries, output = 'flowstride-merged') {
50
+ const root = process.cwd(), out = (0, projectPaths_1.projectPath)(root, output);
51
+ if (node_fs_1.default.existsSync(out))
52
+ throw new Error('Merge output already exists; choose a new directory.');
53
+ if (!summaries.length)
54
+ throw new Error('Provide one summary per shard.');
55
+ const inputs = summaries.map(file => { const absolute = (0, projectPaths_1.projectPath)(root, file); return { file: absolute, report: JSON.parse(node_fs_1.default.readFileSync(absolute, 'utf8')) }; });
56
+ const first = inputs[0].report;
57
+ if (first.protocol !== 1 || !first.distribution?.suiteHash)
58
+ throw new Error('Only native distributed reports can be merged.');
59
+ const total = first.distribution.shard.total, seen = new Set(), seenFiles = new Set(), setup = new Set(first.distribution.setupFiles);
60
+ const errors = [], steps = [], attempts = [], timings = {};
61
+ for (const { report } of inputs) {
62
+ const dist = report.distribution;
63
+ if (report.protocol !== 1 || report.engine !== first.engine || !dist || dist.suiteHash !== first.distribution.suiteHash || dist.configHash !== first.distribution.configHash || dist.shard.total !== total || JSON.stringify(dist.allFiles) !== JSON.stringify(first.distribution.allFiles) || JSON.stringify(dist.setupFiles) !== JSON.stringify(first.distribution.setupFiles))
64
+ throw new Error('Shards belong to different suites or browser configurations.');
65
+ const { index } = parseShard(`${dist.shard.index}/${dist.shard.total}`);
66
+ if (seen.has(index))
67
+ throw new Error('Duplicate shard.');
68
+ seen.add(index);
69
+ if (!['passed', 'failed', 'interrupted'].includes(report.status) || !Array.isArray(report.steps) || !Array.isArray(report.files) || !Array.isArray(report.errors))
70
+ throw new Error('Invalid shard report.');
71
+ if (!report.metrics || ['total', 'passed', 'failed', 'skipped', 'incomplete'].some(key => !Number.isSafeInteger(report.metrics[key]) || report.metrics[key] < 0) || report.metrics.passed + report.metrics.failed + report.metrics.skipped + report.metrics.incomplete !== report.metrics.total)
72
+ throw new Error('Inconsistent shard metrics.');
73
+ if (report.status === 'passed' && (report.errors.length || report.metrics.failed || report.metrics.incomplete || report.steps.some((step) => !['passed', 'skipped', 'not_tested'].includes(step.status))))
74
+ throw new Error('A passing shard contains failures or unfinished work.');
75
+ const duplicateSetup = new Set();
76
+ for (const file of report.files) {
77
+ if (!dist.allFiles.includes(file))
78
+ throw new Error('Unexpected file in shard.');
79
+ if (seenFiles.has(file)) {
80
+ if (!setup.has(file))
81
+ throw new Error('File appears in more than one shard.');
82
+ duplicateSetup.add(file);
83
+ }
84
+ seenFiles.add(file);
85
+ }
86
+ for (const step of report.steps)
87
+ steps.push({ ...step, shard: index });
88
+ attempts.push(...(report.attempts || []).map((attempt) => ({ ...attempt, shard: index })));
89
+ errors.push(...report.errors.map((error) => `Shard ${index}: ${error}`));
90
+ for (const [file, duration] of Object.entries(report.timings || {}))
91
+ if (typeof duration === 'number' && duration >= 0)
92
+ timings[file] = Math.max(timings[file] || 0, duration);
93
+ }
94
+ if (seen.size !== total)
95
+ throw new Error(`Missing shards: expected ${total}, received ${seen.size}.`);
96
+ if (first.distribution.allFiles.some((file) => !seenFiles.has(file)))
97
+ throw new Error('Merged reports omit test files.');
98
+ const statuses = inputs.map(input => input.report.status), status = statuses.includes('interrupted') ? 'interrupted' : statuses.includes('failed') || errors.length ? 'failed' : 'passed';
99
+ const metrics = inputs.reduce((all, { report }) => { for (const key of ['total', 'passed', 'failed', 'skipped', 'incomplete']) {
100
+ const n = report.metrics?.[key];
101
+ if (!Number.isSafeInteger(n) || n < 0)
102
+ throw new Error('Invalid shard metrics.');
103
+ all[key] += n;
104
+ } return all; }, { total: 0, passed: 0, failed: 0, skipped: 0, incomplete: 0 });
105
+ // Preserve shard-level metrics (setup executes independently on each machine).
106
+ const merged = { protocol: 1, engine: first.engine, browser: first.browser, browsers: inputs.flatMap(({ report }) => (report.browsers || []).map((info) => ({ ...info, shard: report.distribution.shard.index }))), status, metrics, steps, attempts, errors, timings, files: [...seenFiles], duration: Math.max(...inputs.map(input => input.report.duration)), distribution: { ...first.distribution, merged: true, setupRunsPerShard: true } };
107
+ node_fs_1.default.mkdirSync(out, { recursive: true, mode: 0o700 });
108
+ try {
109
+ for (const { file, report } of inputs) {
110
+ const source = (0, projectPaths_1.projectPath)(root, node_path_1.default.join(node_path_1.default.dirname(file), node_path_1.default.basename(report.artifactsDir || '')));
111
+ if (!node_fs_1.default.existsSync(source) || !node_fs_1.default.statSync(source).isDirectory())
112
+ throw new Error('Shard evidence directory is missing. Preserve each summary alongside its run directory.');
113
+ const destination = node_path_1.default.join(out, `shard-${report.distribution.shard.index}`);
114
+ node_fs_1.default.mkdirSync(destination);
115
+ let bytes = 0;
116
+ for (const name of node_fs_1.default.readdirSync(source)) {
117
+ const item = (0, projectPaths_1.projectPath)(root, node_path_1.default.join(source, name)), stat = node_fs_1.default.lstatSync(item);
118
+ if (!stat.isFile() || stat.isSymbolicLink())
119
+ throw new Error('Shard evidence must contain regular files only.');
120
+ bytes += stat.size;
121
+ if (bytes > 500 * 1024 * 1024)
122
+ throw new Error('Shard evidence exceeds the 500 MiB merge limit.');
123
+ node_fs_1.default.copyFileSync(item, node_path_1.default.join(destination, name));
124
+ }
125
+ }
126
+ node_fs_1.default.writeFileSync(node_path_1.default.join(out, 'summary.json'), JSON.stringify(merged, null, 2));
127
+ node_fs_1.default.writeFileSync(node_path_1.default.join(out, 'timings.json'), JSON.stringify(timings, null, 2));
128
+ return merged;
129
+ }
130
+ catch (error) {
131
+ node_fs_1.default.rmSync(out, { recursive: true, force: true });
132
+ throw error;
133
+ }
134
+ }
package/dist/ci/init.d.ts CHANGED
@@ -1 +1,4 @@
1
- export declare function initCI(provider: string): void;
1
+ export declare function initCI(provider: string, options?: {
2
+ browsers?: string;
3
+ shards?: string;
4
+ }): void;
package/dist/ci/init.js CHANGED
@@ -7,15 +7,26 @@ exports.initCI = initCI;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const config_1 = require("./config");
10
- function initCI(provider) {
10
+ function initCI(provider, options = {}) {
11
11
  if (provider !== 'github')
12
12
  throw new Error('Supported provider: github.');
13
+ const browsers = (options.browsers || 'chromium').split(',');
14
+ const shards = Number(options.shards || 1);
15
+ if (!browsers.length || new Set(browsers).size !== browsers.length || browsers.some(browser => !['chromium', 'firefox', 'webkit'].includes(browser)))
16
+ throw new Error('Choose unique browser names separated by commas.');
17
+ if (!Number.isSafeInteger(shards) || shards < 1 || shards > 256)
18
+ throw new Error('shards must be 1..256.');
19
+ const matrix = browsers.length > 1 || shards > 1;
20
+ const strategy = matrix ? `\n strategy:\n fail-fast: false\n matrix:\n browser: ${JSON.stringify(browsers)}\n shard: ${JSON.stringify(Array.from({ length: shards }, (_, index) => index + 1))}` : '';
21
+ const selection = matrix ? ' --browser ${{ matrix.browser }} --shard ${{ matrix.shard }}/' + shards : browsers[0] !== 'chromium' ? ' --browser ' + browsers[0] : '';
22
+ const install = matrix ? ' ${{ matrix.browser }}' : ' ' + browsers[0];
23
+ const artifact = matrix ? 'flowstride-results-${{ matrix.browser }}-${{ matrix.shard }}' : 'flowstride-results';
13
24
  const file = node_path_1.default.resolve('.github/workflows/flowstride.yml');
14
25
  if (node_fs_1.default.existsSync(file))
15
26
  throw new Error('Flowstride workflow already exists; it was not overwritten.');
16
27
  const artifacts = node_path_1.default.relative(process.cwd(), (0, config_1.loadNativeConfig)(process.cwd(), {}).artifactsDir);
17
28
  const parent = node_path_1.default.dirname(file);
18
29
  node_fs_1.default.mkdirSync(parent, { recursive: true });
19
- node_fs_1.default.writeFileSync(file, `name: Flowstride\non:\n push:\n pull_request:\n workflow_dispatch:\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 20\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262\n with:\n persist-credentials: false\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020\n with:\n node-version: 24\n cache: npm\n - run: npm ci\n - name: Install matching browser dependencies\n run: npx --no-install flowstride install --with-deps\n - name: Run native Flowstride\n run: npx --no-install flowstride run --ci\n - name: Retain test evidence\n if: always()\n uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02\n with:\n name: flowstride-results\n path: ${JSON.stringify(artifacts + "/")}\n retention-days: 14\n`, { flag: 'wx' });
30
+ node_fs_1.default.writeFileSync(file, `name: Flowstride\non:\n push:\n pull_request:\n workflow_dispatch:\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 20${strategy}\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262\n with:\n persist-credentials: false\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020\n with:\n node-version: 24\n cache: npm\n - run: npm ci\n - name: Install matching browser dependencies\n run: npx --no-install flowstride install --with-deps${install}\n - name: Run native Flowstride\n run: npx --no-install flowstride run --ci${selection}\n - name: Retain test evidence\n if: always()\n uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02\n with:\n name: ${artifact}\n path: ${JSON.stringify(artifacts + "/")}\n retention-days: 14\n`, { flag: 'wx' });
20
31
  console.log(`Created ${file}. Commit it when ready. This template uses the project-locked Flowstride installation.`);
21
32
  }
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runNativeCI = runNativeCI;
7
+ const assertion_result_1 = require("../reporting/assertion_result");
8
+ const distribution_1 = require("./distribution");
7
9
  const node_crypto_1 = require("node:crypto");
8
10
  const projectPaths_1 = require("../utils/projectPaths");
9
11
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -20,6 +22,11 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
20
22
  node_fs_1.default.mkdirSync(artifactRoot, { recursive: true });
21
23
  const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(artifactRoot, 'run-'));
22
24
  config.artifactsDir = directory;
25
+ const plan = (0, distribution_1.executionPlan)(target, config);
26
+ const browsers = new Map();
27
+ const attempts = [];
28
+ const attemptSteps = new Map();
29
+ const timings = {};
23
30
  const steps = new Map();
24
31
  const completed = new Set();
25
32
  const errors = [];
@@ -34,8 +41,18 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
34
41
  if (!data?.payload)
35
42
  return;
36
43
  const p = data.payload;
44
+ if (data.type === 'BROWSER_INFO')
45
+ browsers.set(p.fileName, { ...p });
37
46
  if (data.type === 'BAIL')
38
47
  bailed = true;
48
+ if (data.type === 'SCENARIO_ATTEMPT')
49
+ attemptSteps.set(p.fileName, []);
50
+ if (data.type === 'SCENARIO_RESULT')
51
+ attempts.push({ ...p, steps: [...(attemptSteps.get(p.fileName) || [])] });
52
+ if (data.type === 'FILE_COMPLETE' || data.type === 'FILE_FAILED')
53
+ timings[p.fileName] = p.duration;
54
+ if (data.type === 'STEP_UPDATE')
55
+ attemptSteps.get(p.fileName)?.push({ ...p, screenshot: undefined });
39
56
  if (data.type === 'LOG_EVENT' && p.status === 'error') {
40
57
  const active = [...steps.values()].reverse().find(step => step.fileName === p.fileName && step.status === 'running');
41
58
  if (active)
@@ -69,9 +86,9 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
69
86
  process.on('SIGINT', interrupt);
70
87
  process.on('SIGTERM', interrupt);
71
88
  try {
72
- files = (0, projectPaths_1.discoverFlows)(target, config.projectRoot || process.cwd());
89
+ files = plan.files;
73
90
  config.flowRoot = node_fs_1.default.statSync(target).isDirectory() ? target : node_path_1.default.dirname(target);
74
- if (!files.length)
91
+ if (!plan.allFiles.length)
75
92
  throw new Error('No .flow tests found.');
76
93
  for (const file of files) {
77
94
  const features = (0, projectPaths_1.selectedFeatures)(new parser_1.Parser(new lexer_1.Lexer(node_fs_1.default.readFileSync(file, 'utf8')).tokenise()).parse());
@@ -83,7 +100,7 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
83
100
  }
84
101
  expected += features.reduce((n, feature) => n + feature.scenarios.reduce((m, scenario) => m + scenario.steps.length, 0), 0);
85
102
  }
86
- if (!expected)
103
+ if (files.length && !expected)
87
104
  throw new Error('No executable test steps found.');
88
105
  if (config.webServer) {
89
106
  const settings = config.webServer;
@@ -175,8 +192,10 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
175
192
  errors.push('Engine produced an unexpected step count.');
176
193
  const metrics = { total: expected, passed: results.filter(s => s.status === 'passed').length,
177
194
  failed: results.filter(s => s.status === 'failed').length, skipped: results.filter(s => ['skipped', 'not_tested'].includes(s.status)).length, incomplete };
178
- const result = { cloudSync: config.cloudSyncResult, protocol: 1, engine, status: interrupted ? 'interrupted' : errors.length || metrics.failed || incomplete ? 'failed' : 'passed',
179
- duration: Date.now() - started, artifactsDir: directory, files: files.map(file => (0, projectPaths_1.flowIdentity)(file, config.flowRoot)), steps: results, errors, metrics };
195
+ if (config.failOnFlaky && attempts.some(attempt => attempt.flaky))
196
+ errors.push('Flaky scenarios are configured to fail CI.');
197
+ const result = { cloudSync: config.cloudSyncResult, browser: config.browser || 'chromium', browsers: [...browsers.values()], protocol: 1, engine, status: interrupted ? 'interrupted' : errors.length || metrics.failed || incomplete ? 'failed' : 'passed',
198
+ duration: Date.now() - started, artifactsDir: directory, files: files.map(file => (0, projectPaths_1.flowIdentity)(file, config.flowRoot)), steps: results, errors, metrics, attempts, timings, distribution: { ...plan, files: undefined, flowRoot: undefined } };
180
199
  // Existing workers finalize videos into these locations. Copy only run files,
181
200
  // never the project directory or credentials.
182
201
  if (config.recordVideo !== false)
@@ -190,8 +209,9 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
190
209
  }
191
210
  }
192
211
  config.artifactsDir = artifactRoot;
212
+ node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'timings.json'), JSON.stringify(timings, null, 2));
193
213
  node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'summary.json'), JSON.stringify(result, null, 2));
194
- const cases = results.map(step => `<testcase classname="${xml(step.fileName)}" name="${xml(step.description || step.id)}">${step.status === 'failed' ? `<failure message="${xml(step.error || 'Test step failed')}"/>` : ['skipped', 'not_tested'].includes(step.status) ? '<skipped/>' : ''}</testcase>`);
214
+ const cases = results.map(step => `<testcase classname="${xml(step.fileName)}" name="${xml(step.description || step.id)}">${step.status === 'failed' ? `<failure message="${xml(step.error || 'Test step failed')}"/>` : ['skipped', 'not_tested'].includes(step.status) ? '<skipped/>' : ''}${step.assertion ? `<system-out>${xml((0, assertion_result_1.assertionText)(step.assertion))}</system-out>` : ''}</testcase>`);
195
215
  for (const error of errors)
196
216
  cases.push(`<testcase name="execution"><error message="${xml(error)}"/></testcase>`);
197
217
  if (incomplete)
@@ -199,5 +219,12 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
199
219
  node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'junit.xml'), `<?xml version="1.0"?><testsuite name="Flowstride" tests="${cases.length}" failures="${metrics.failed + (incomplete ? 1 : 0)}" errors="${errors.length}" skipped="${metrics.skipped}">${cases.join('')}</testsuite>`);
200
220
  if (process.env.GITHUB_STEP_SUMMARY)
201
221
  node_fs_1.default.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n### Flowstride · ${config.device}\nResult: **${result.status}** (${engine})\n\nPassed: ${metrics.passed}; failed: ${metrics.failed}; skipped: ${metrics.skipped}; incomplete: ${metrics.incomplete}.\n`);
222
+ if (process.env.GITHUB_STEP_SUMMARY) {
223
+ const failures = results.filter(step => step.status === 'failed').map(step => `<details><summary>${xml(step.commandLabel || step.command || 'Step')} · ${xml(step.fileName)}</summary><pre>${xml(step.error || (step.assertion ? (0, assertion_result_1.assertionText)(step.assertion) : 'Failed without an adapter diagnostic.'))}</pre></details>`);
224
+ for (const error of errors)
225
+ failures.push(`<pre>${xml(error)}</pre>`);
226
+ if (failures.length)
227
+ node_fs_1.default.appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\n' + failures.join('\n') + '\n');
228
+ }
202
229
  return result;
203
230
  }
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.runAction = runAction;
37
+ const runtime_failure_1 = require("../../reporting/runtime_failure");
38
+ const compatibility_1 = require("../../native/compatibility");
37
39
  const projectPaths_1 = require("../../utils/projectPaths");
38
40
  const config_1 = require("../../ci/config");
39
41
  const runtime_1 = require("../../ci/runtime");
@@ -121,12 +123,16 @@ async function runAction(targetPath, options) {
121
123
  const proOrchModule = await import((0, url_1.pathToFileURL)(proOrchPath).href);
122
124
  if (config.ci && proOrchModule.FlowOrchestrator.nativeCIProtocol !== 1)
123
125
  throw new Error('Installed Pro engine does not support native CI protocol 1; install the matching Pro candidate.');
126
+ if ((0, compatibility_1.needsNativeBrowserProtocol)(resolvedPath, config) && (proWorkerModule.FlowWorker.nativeBrowserProtocol !== 2 || proOrchModule.FlowOrchestrator.nativeBrowserProtocol !== 2))
127
+ throw new Error('Installed Cloud engine does not support these native browser capabilities. Install the matching Cloud archive; this run will use OS fallback.');
128
+ if ((0, compatibility_1.needsNativeBrowserProtocol)(resolvedPath, config) && (proWorkerModule.FlowWorker.nativeAssertionProtocol !== 1 || proOrchModule.FlowOrchestrator.nativeAssertionProtocol !== 1))
129
+ throw new Error('Installed Cloud engine predates structured assertion reporting; this run will use OS fallback. Install the matching Cloud archive to update.');
124
130
  ActiveFlowWorker = proWorkerModule.FlowWorker;
125
131
  ActiveFlowOrchestrator = proOrchModule.FlowOrchestrator;
126
132
  engineMode = "Enterprise (Pro)";
127
133
  }
128
134
  catch (err) {
129
- console.warn(`\n[Warning]: Found @flowstride/pro but failed to boot it. Falling back to Open Source engine. (${err.message})`);
135
+ console.warn(`\n[Warning]: Found @flowstride/pro but failed to boot it. Falling back to Open Source engine. (${(0, runtime_failure_1.runtimeFailure)("run.setup", { path: resolvedPath }, err, { file: resolvedPath })})`);
130
136
  }
131
137
  }
132
138
  console.log(`\n[Engine]: Booting Flowstride in ${engineMode} mode...`);
@@ -137,9 +143,16 @@ async function runAction(targetPath, options) {
137
143
  console.log(`[Flowstride]: ${result.status}; reports: ${config.artifactsDir}`);
138
144
  process.exit(result.status === 'passed' ? 0 : result.status === 'interrupted' ? 130 : 1);
139
145
  }
146
+ const previousRunEvent = config.onRunEvent;
147
+ config.onRunEvent = event => {
148
+ previousRunEvent?.(event);
149
+ if (event.type === 'RUN_COMPLETE')
150
+ process.exitCode = event.payload?.status === 'success' ? 0 : ['aborted', 'interrupted'].includes(event.payload?.status) ? 130 : 1;
151
+ };
140
152
  try {
141
- await orchestrator.start(resolvedPath);
142
- console.log("\n[Flowstride]: Execution suite completed successfully.");
153
+ const isSuccess = await orchestrator.start(resolvedPath);
154
+ process.exitCode = isSuccess ? 0 : 1;
155
+ console.log(isSuccess ? "\n[Flowstride]: Execution suite completed successfully." : "\n[Flowstride]: Execution suite failed.");
143
156
  if (isCI) {
144
157
  const artifactsDir = path.resolve(root, "flowstride-artifacts");
145
158
  if (!fs.existsSync(artifactsDir)) {
@@ -154,7 +167,7 @@ async function runAction(targetPath, options) {
154
167
  }
155
168
  }
156
169
  catch (error) {
157
- console.error("\n[Fatal Error]:", error.message);
170
+ console.error("\n[Fatal Error]:", (0, runtime_failure_1.runtimeFailure)("run.setup", { path: resolvedPath }, error, { file: resolvedPath }));
158
171
  if (isCI) {
159
172
  console.log(`[CI Alert]: Terminating pipeline with exit code 1 to block deployment.`);
160
173
  }
package/dist/cli/index.js CHANGED
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
+ const distribution_1 = require("../ci/distribution");
41
+ const projectPaths_1 = require("../utils/projectPaths");
40
42
  const cloud_1 = require("../utils/cloud");
41
43
  const cloudSession_1 = require("./utils/cloudSession");
42
44
  const config_1 = require("../ci/config");
@@ -63,7 +65,7 @@ let API_BASE = (0, cloud_1.cloudApiBase)(process.env);
63
65
  program
64
66
  .name("flowstride")
65
67
  .description("Flowstride: The Enterprise Flow-First Automation CLI")
66
- .version("1.0.20");
68
+ .version(require("../../package.json").version);
67
69
  program
68
70
  .command("init")
69
71
  .description("Initialise a new Flowstride workspace in the current directory")
@@ -258,6 +260,10 @@ program
258
260
  .option("--workers <number>", "Number of parallel workers to spawn (Pro feature)")
259
261
  .option('--ci', 'Run without a dashboard and write CI reports')
260
262
  .option('--device <profile>', 'desktop, phone, or landscape')
263
+ .option('--browser <engine>', 'chromium, firefox, or webkit')
264
+ .option('--shard <index/total>', 'Run one deterministic CI shard, e.g. 1/4')
265
+ .option('--shard-history <file>', 'Use merged timing history for shard balancing')
266
+ .option('--update-baselines', 'Explicitly update ARIA baselines locally')
261
267
  .option('--config <file>', 'Path to the Flowstride configuration')
262
268
  .option('--env <name>', 'Environment profile')
263
269
  .action(async (targetPath, options) => {
@@ -394,15 +400,28 @@ program
394
400
  await (0, open_1.openAction)();
395
401
  });
396
402
  program.command('install')
397
- .description('Install Chromium matching this Flowstride engine')
403
+ .description('Install browsers matching this Flowstride engine')
404
+ .argument('[browsers...]', 'chromium, firefox, or webkit')
398
405
  .option('--with-deps', 'Install operating-system browser dependencies')
399
- .action((options) => {
406
+ .action((browsers, options) => {
407
+ if (browsers.some(browser => !['chromium', 'firefox', 'webkit'].includes(browser)))
408
+ throw new Error('Unknown browser.');
400
409
  const { execFileSync } = require('node:child_process');
401
- execFileSync(process.execPath, [path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', ...(options.withDeps ? ['--with-deps'] : []), 'chromium'], { stdio: 'inherit', env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_GC: '1' } });
410
+ execFileSync(process.execPath, [path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', ...(options.withDeps ? ['--with-deps'] : []), ...(browsers.length ? browsers : ['chromium'])], { stdio: 'inherit', env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_GC: '1' } });
411
+ console.log(`[Browsers]: Matching binaries installed: ${(browsers.length ? browsers : ['chromium']).join(', ')}. Use run --browser <engine> to select one.`);
402
412
  });
403
413
  program.command('ci')
404
414
  .description('Set up native Flowstride CI')
405
415
  .command('init <provider>')
406
416
  .description('Generate a GitHub Actions workflow without overwriting existing files')
407
- .action((provider) => (0, init_1.initCI)(provider));
417
+ .option('--browsers <names>', 'Comma-separated browser matrix', 'chromium')
418
+ .option('--shards <total>', 'Number of CI machines per browser', '1')
419
+ .action((provider, options) => (0, init_1.initCI)(provider, options));
420
+ program.command('merge <summaries...>')
421
+ .description('Merge complete native shard results and evidence')
422
+ .option('--output <directory>', 'New project directory for merged results', 'flowstride-merged')
423
+ .action((summaries, options) => { const result = (0, distribution_1.mergeResults)(summaries, options.output); console.log(`Merged Flowstride result: ${result.status}`); process.exitCode = result.status === 'passed' ? 0 : 1; });
424
+ program.command('trace <file>')
425
+ .description('Open a local Playwright trace without uploading it')
426
+ .action((file) => { const { execFileSync } = require('node:child_process'); const local = (0, projectPaths_1.projectPath)(process.cwd(), file); execFileSync(process.execPath, [path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'), 'show-trace', local], { stdio: 'inherit' }); });
408
427
  program.parse(process.argv);
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.startManualWorkspaceServer = startManualWorkspaceServer;
40
+ const localWorkspace_1 = require("../../security/localWorkspace");
40
41
  const projectPaths_1 = require("../../utils/projectPaths");
41
42
  const security_1 = require("../../manual/security");
42
43
  const http = __importStar(require("http"));
@@ -44,6 +45,72 @@ const path = __importStar(require("path"));
44
45
  const fs = __importStar(require("fs"));
45
46
  const ws_1 = require("ws");
46
47
  const chalk_1 = __importDefault(require("chalk"));
48
+ /** Complete local persistence before publishing a usable media URL. */
49
+ function receiveManualUpload(req, res, filePath, maxSize, limitMessage, onSaved, onChunk) {
50
+ let size = 0;
51
+ let saved = false;
52
+ let failure;
53
+ const output = fs.createWriteStream(filePath);
54
+ const sendFailure = () => {
55
+ if (!res.destroyed && !res.writableEnded && !res.headersSent) {
56
+ res.writeHead(failure?.status || 500, { 'Content-Type': 'application/json' });
57
+ res.end(JSON.stringify({ error: failure?.message || 'Upload stream failed' }));
58
+ }
59
+ };
60
+ const fail = (status, message) => {
61
+ if (saved || failure)
62
+ return;
63
+ failure = { status, message };
64
+ output.destroy();
65
+ // Drain the rejected request without buffering it so the error can be sent.
66
+ req.resume();
67
+ };
68
+ output.on('error', () => fail(500, 'Upload stream failed'));
69
+ output.once('close', () => {
70
+ if (saved)
71
+ return;
72
+ // An early destroy may race the asynchronous file open. Cleanup belongs
73
+ // after close, when a partial file can no longer be recreated or written.
74
+ try {
75
+ fs.unlinkSync(filePath);
76
+ }
77
+ catch (error) {
78
+ if (error.code !== 'ENOENT')
79
+ console.warn('[Upload]: Could not remove a partial media file.');
80
+ }
81
+ sendFailure();
82
+ });
83
+ output.once('finish', () => {
84
+ if (failure)
85
+ return;
86
+ saved = true;
87
+ Promise.resolve().then(() => onSaved(size)).catch(() => {
88
+ failure = { status: 500, message: 'Upload completion failed' };
89
+ sendFailure();
90
+ });
91
+ });
92
+ req.on('data', (chunk) => {
93
+ if (failure || saved)
94
+ return;
95
+ size += chunk.length;
96
+ if (size > maxSize) {
97
+ fail(413, limitMessage);
98
+ return;
99
+ }
100
+ onChunk?.(chunk);
101
+ if (!output.write(chunk)) {
102
+ req.pause();
103
+ output.once('drain', () => { if (!failure)
104
+ req.resume(); });
105
+ }
106
+ });
107
+ req.once('end', () => { if (!failure)
108
+ output.end(); });
109
+ req.once('aborted', () => fail(400, 'Upload aborted'));
110
+ req.once('error', () => fail(500, 'Upload stream failed'));
111
+ res.once('close', () => { if (!res.writableFinished)
112
+ fail(400, 'Upload aborted'); });
113
+ }
47
114
  async function startManualWorkspaceServer() {
48
115
  const cliInstallPath = path.resolve(__dirname, "../../..");
49
116
  const uiDistPath = path.join(cliInstallPath, "src", "ui", "dist");
@@ -74,9 +141,18 @@ async function startManualWorkspaceServer() {
74
141
  ".webm": "video/webm",
75
142
  };
76
143
  const httpServer = http.createServer(async (req, res) => {
77
- res.setHeader("Access-Control-Allow-Origin", "*");
78
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
79
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-File-Extension");
144
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
145
+ const fetchSite = typeof req.headers['sec-fetch-site'] === 'string' ? req.headers['sec-fetch-site'] : undefined;
146
+ if (!(0, localWorkspace_1.isLocalWorkspaceRequest)(req.headers.host, origin, req.socket.localPort, fetchSite)) {
147
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
148
+ res.end('Forbidden');
149
+ return;
150
+ }
151
+ if (origin)
152
+ res.setHeader('Access-Control-Allow-Origin', origin);
153
+ res.setHeader('Vary', 'Origin');
154
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
155
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-File-Extension');
80
156
  if (req.method === "OPTIONS") {
81
157
  res.writeHead(204);
82
158
  return res.end();
@@ -95,43 +171,11 @@ async function startManualWorkspaceServer() {
95
171
  }
96
172
  const safeFilename = `media_${Date.now()}_${Math.random().toString(36).substring(2, 9)}${ext.toLowerCase()}`;
97
173
  const filePath = (0, projectPaths_1.projectPath)(process.cwd(), path.join(mediaDir, safeFilename));
98
- const MAX_SIZE = 104857600;
99
- let uploadedSize = 0;
100
- let isAborted = false;
101
- const writeStream = fs.createWriteStream(filePath);
102
- req.on("data", (chunk) => {
103
- if (isAborted)
104
- return;
105
- uploadedSize += chunk.length;
106
- if (uploadedSize > MAX_SIZE) {
107
- isAborted = true;
108
- writeStream.destroy();
109
- if (fs.existsSync(filePath))
110
- fs.unlinkSync(filePath);
111
- res.writeHead(413, { "Content-Type": "application/json" });
112
- res.end(JSON.stringify({ error: "Payload Too Large. Max size is 100MB." }));
113
- }
114
- else {
115
- writeStream.write(chunk);
116
- }
117
- });
118
- req.on("end", () => {
119
- if (isAborted)
120
- return;
121
- writeStream.end();
174
+ receiveManualUpload(req, res, filePath, 104857600, 'Payload Too Large. Max size is 100MB.', (uploadedSize) => {
122
175
  console.log(chalk_1.default.blue(`\n[Reference Uploaded]: Securely saved ${safeFilename} (${(uploadedSize / 1024 / 1024).toFixed(2)} MB)`));
123
- res.writeHead(200, { "Content-Type": "application/json" });
176
+ res.writeHead(200, { 'Content-Type': 'application/json' });
124
177
  res.end(JSON.stringify({ url: `/media/${safeFilename}` }));
125
178
  });
126
- req.on("error", (err) => {
127
- if (!isAborted) {
128
- writeStream.destroy();
129
- if (fs.existsSync(filePath))
130
- fs.unlinkSync(filePath);
131
- res.writeHead(500, { "Content-Type": "application/json" });
132
- res.end(JSON.stringify({ error: "Upload stream failed" }));
133
- }
134
- });
135
179
  return;
136
180
  }
137
181
  if (req.method === "DELETE" && pathname === "/api/media") {
@@ -216,44 +260,12 @@ async function startManualWorkspaceServer() {
216
260
  }
217
261
  const safeFilename = `defect_${Date.now()}_${Math.random().toString(36).substring(2, 9)}${ext.toLowerCase()}`;
218
262
  const filePath = (0, projectPaths_1.projectPath)(process.cwd(), path.join(defectMediaDir, safeFilename));
219
- const MAX_SIZE = 41943040; // Capped at 40MB
220
- let uploadedSize = 0;
221
- let isAborted = false;
222
- const writeStream = fs.createWriteStream(filePath);
223
- req.on("data", (chunk) => {
224
- if (isAborted)
225
- return;
226
- uploadedSize += chunk.length;
227
- if (uploadedSize > MAX_SIZE) {
228
- isAborted = true;
229
- writeStream.destroy();
230
- if (fs.existsSync(filePath))
231
- fs.unlinkSync(filePath);
232
- res.writeHead(413, { "Content-Type": "application/json" });
233
- res.end(JSON.stringify({ error: "Payload Too Large. Max size is 40MB." }));
234
- }
235
- else {
236
- writeStream.write(chunk);
237
- }
238
- });
239
- req.on("end", async () => {
240
- if (isAborted)
241
- return;
242
- writeStream.end();
263
+ receiveManualUpload(req, res, filePath, 41943040, 'Payload Too Large. Max size is 40MB.', () => {
243
264
  const finalUrl = `/defect_media/${safeFilename}`;
244
265
  console.log(chalk_1.default.green(`\n[Vault Success]: Defect Media secured locally at -> ${finalUrl}`));
245
- res.writeHead(200, { "Content-Type": "application/json" });
266
+ res.writeHead(200, { 'Content-Type': 'application/json' });
246
267
  res.end(JSON.stringify({ url: finalUrl }));
247
268
  });
248
- req.on("error", (err) => {
249
- if (!isAborted) {
250
- writeStream.destroy();
251
- if (fs.existsSync(filePath))
252
- fs.unlinkSync(filePath);
253
- res.writeHead(500, { "Content-Type": "application/json" });
254
- res.end(JSON.stringify({ error: "Upload stream failed" }));
255
- }
256
- });
257
269
  return;
258
270
  }
259
271
  if (req.method === "DELETE" && pathname === "/api/defect-media") {
@@ -727,7 +739,11 @@ async function startManualWorkspaceServer() {
727
739
  }
728
740
  }
729
741
  });
730
- const wss = new ws_1.WebSocketServer({ server: httpServer });
742
+ const wss = new ws_1.WebSocketServer({
743
+ server: httpServer,
744
+ verifyClient: (info) => Boolean(info.origin) &&
745
+ (0, localWorkspace_1.isLocalWorkspaceRequest)(info.req.headers.host, info.origin, info.req.socket.localPort, typeof info.req.headers['sec-fetch-site'] === 'string' ? info.req.headers['sec-fetch-site'] : undefined),
746
+ });
731
747
  wss.on("connection", (ws) => {
732
748
  ws.send(JSON.stringify({ type: "CLI_CONTEXT", payload: { command: "open" } }));
733
749
  ws.on("error", (err) => {
@@ -738,7 +754,7 @@ async function startManualWorkspaceServer() {
738
754
  console.error(chalk_1.default.red(`\n[Error]: Failed to start manual workspace server: ${e.message}`));
739
755
  process.exit(1);
740
756
  });
741
- httpServer.listen(0, async () => {
757
+ httpServer.listen(0, '127.0.0.1', async () => {
742
758
  try {
743
759
  const address = httpServer.address();
744
760
  const port = typeof address === "object" && address !== null ? address.port : null;
@@ -746,7 +762,7 @@ async function startManualWorkspaceServer() {
746
762
  console.error(chalk_1.default.red("\n[Error]: Could not bind to an available port."));
747
763
  process.exit(1);
748
764
  }
749
- const localUrl = `http://localhost:${port}`;
765
+ const localUrl = `http://127.0.0.1:${port}`;
750
766
  console.log(chalk_1.default.yellow(`\n[Workspace]: Manual Design server running at ${localUrl}`));
751
767
  const { default: open } = await import("open");
752
768
  await open(localUrl);