flowstride 1.0.20 → 1.0.21

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 (47) hide show
  1. package/NATIVE_BROWSER.md +341 -0
  2. package/NATIVE_CI.md +3 -3
  3. package/README.md +3 -3
  4. package/dist/adapters/api/undici.d.ts +8 -0
  5. package/dist/adapters/api/undici.js +11 -0
  6. package/dist/adapters/web/playwright.d.ts +22 -2
  7. package/dist/adapters/web/playwright.js +233 -36
  8. package/dist/ci/config.js +14 -1
  9. package/dist/ci/distribution.d.ts +38 -0
  10. package/dist/ci/distribution.js +134 -0
  11. package/dist/ci/init.d.ts +4 -1
  12. package/dist/ci/init.js +13 -2
  13. package/dist/ci/runtime.js +24 -5
  14. package/dist/cli/commands/run.js +3 -0
  15. package/dist/cli/index.js +24 -5
  16. package/dist/manual/security.d.ts +2 -2
  17. package/dist/native/accessibility.d.ts +3 -0
  18. package/dist/native/accessibility.js +68 -0
  19. package/dist/native/assertions.d.ts +2 -0
  20. package/dist/native/assertions.js +140 -0
  21. package/dist/native/compatibility.d.ts +3 -0
  22. package/dist/native/compatibility.js +16 -0
  23. package/dist/native/components.d.ts +12 -0
  24. package/dist/native/components.js +122 -0
  25. package/dist/native/dashboard.d.ts +7 -0
  26. package/dist/native/dashboard.js +32 -0
  27. package/dist/native/events.d.ts +34 -0
  28. package/dist/native/events.js +281 -0
  29. package/dist/native/evidence.d.ts +3 -0
  30. package/dist/native/evidence.js +31 -0
  31. package/dist/native/functions.d.ts +9 -0
  32. package/dist/native/functions.js +103 -0
  33. package/dist/native/runtime.d.ts +14 -0
  34. package/dist/native/runtime.js +154 -0
  35. package/dist/native/validation.d.ts +107 -0
  36. package/dist/native/validation.js +84 -0
  37. package/dist/parser/lexer.js +15 -4
  38. package/dist/parser/parser.js +1 -1
  39. package/dist/runner/flow_orchestrator.d.ts +4 -0
  40. package/dist/runner/flow_orchestrator.js +22 -5
  41. package/dist/runner/flow_worker.d.ts +7 -0
  42. package/dist/runner/flow_worker.js +102 -3
  43. package/dist/types/index.d.ts +10 -0
  44. package/package.json +20 -9
  45. package/src/ui/dist/assets/index-DglsL_J2.js +636 -0
  46. package/src/ui/dist/index.html +1 -1
  47. package/src/ui/dist/assets/index-B7MuBTWm.js +0 -460
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runNativeCI = runNativeCI;
7
+ const distribution_1 = require("./distribution");
7
8
  const node_crypto_1 = require("node:crypto");
8
9
  const projectPaths_1 = require("../utils/projectPaths");
9
10
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -20,6 +21,11 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
20
21
  node_fs_1.default.mkdirSync(artifactRoot, { recursive: true });
21
22
  const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(artifactRoot, 'run-'));
22
23
  config.artifactsDir = directory;
24
+ const plan = (0, distribution_1.executionPlan)(target, config);
25
+ const browsers = new Map();
26
+ const attempts = [];
27
+ const attemptSteps = new Map();
28
+ const timings = {};
23
29
  const steps = new Map();
24
30
  const completed = new Set();
25
31
  const errors = [];
@@ -34,8 +40,18 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
34
40
  if (!data?.payload)
35
41
  return;
36
42
  const p = data.payload;
43
+ if (data.type === 'BROWSER_INFO')
44
+ browsers.set(p.fileName, { ...p });
37
45
  if (data.type === 'BAIL')
38
46
  bailed = true;
47
+ if (data.type === 'SCENARIO_ATTEMPT')
48
+ attemptSteps.set(p.fileName, []);
49
+ if (data.type === 'SCENARIO_RESULT')
50
+ attempts.push({ ...p, steps: [...(attemptSteps.get(p.fileName) || [])] });
51
+ if (data.type === 'FILE_COMPLETE' || data.type === 'FILE_FAILED')
52
+ timings[p.fileName] = p.duration;
53
+ if (data.type === 'STEP_UPDATE')
54
+ attemptSteps.get(p.fileName)?.push({ ...p, screenshot: undefined });
39
55
  if (data.type === 'LOG_EVENT' && p.status === 'error') {
40
56
  const active = [...steps.values()].reverse().find(step => step.fileName === p.fileName && step.status === 'running');
41
57
  if (active)
@@ -69,9 +85,9 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
69
85
  process.on('SIGINT', interrupt);
70
86
  process.on('SIGTERM', interrupt);
71
87
  try {
72
- files = (0, projectPaths_1.discoverFlows)(target, config.projectRoot || process.cwd());
88
+ files = plan.files;
73
89
  config.flowRoot = node_fs_1.default.statSync(target).isDirectory() ? target : node_path_1.default.dirname(target);
74
- if (!files.length)
90
+ if (!plan.allFiles.length)
75
91
  throw new Error('No .flow tests found.');
76
92
  for (const file of files) {
77
93
  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 +99,7 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
83
99
  }
84
100
  expected += features.reduce((n, feature) => n + feature.scenarios.reduce((m, scenario) => m + scenario.steps.length, 0), 0);
85
101
  }
86
- if (!expected)
102
+ if (files.length && !expected)
87
103
  throw new Error('No executable test steps found.');
88
104
  if (config.webServer) {
89
105
  const settings = config.webServer;
@@ -175,8 +191,10 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
175
191
  errors.push('Engine produced an unexpected step count.');
176
192
  const metrics = { total: expected, passed: results.filter(s => s.status === 'passed').length,
177
193
  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 };
194
+ if (config.failOnFlaky && attempts.some(attempt => attempt.flaky))
195
+ errors.push('Flaky scenarios are configured to fail CI.');
196
+ 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',
197
+ 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
198
  // Existing workers finalize videos into these locations. Copy only run files,
181
199
  // never the project directory or credentials.
182
200
  if (config.recordVideo !== false)
@@ -190,6 +208,7 @@ async function runNativeCI(worker, orchestrator, target, config, engine) {
190
208
  }
191
209
  }
192
210
  config.artifactsDir = artifactRoot;
211
+ node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'timings.json'), JSON.stringify(timings, null, 2));
193
212
  node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'summary.json'), JSON.stringify(result, null, 2));
194
213
  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>`);
195
214
  for (const error of errors)
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.runAction = runAction;
37
+ const compatibility_1 = require("../../native/compatibility");
37
38
  const projectPaths_1 = require("../../utils/projectPaths");
38
39
  const config_1 = require("../../ci/config");
39
40
  const runtime_1 = require("../../ci/runtime");
@@ -121,6 +122,8 @@ async function runAction(targetPath, options) {
121
122
  const proOrchModule = await import((0, url_1.pathToFileURL)(proOrchPath).href);
122
123
  if (config.ci && proOrchModule.FlowOrchestrator.nativeCIProtocol !== 1)
123
124
  throw new Error('Installed Pro engine does not support native CI protocol 1; install the matching Pro candidate.');
125
+ if ((0, compatibility_1.needsNativeBrowserProtocol)(resolvedPath, config) && (proWorkerModule.FlowWorker.nativeBrowserProtocol !== 2 || proOrchModule.FlowOrchestrator.nativeBrowserProtocol !== 2))
126
+ throw new Error('Installed Cloud engine does not support these native browser capabilities. Install the matching Cloud archive; this run will use OS fallback.');
124
127
  ActiveFlowWorker = proWorkerModule.FlowWorker;
125
128
  ActiveFlowOrchestrator = proOrchModule.FlowOrchestrator;
126
129
  engineMode = "Enterprise (Pro)";
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);
@@ -61,6 +61,7 @@ export declare const manualRunSchema: z.ZodObject<{
61
61
  failed: "failed";
62
62
  skipped: "skipped";
63
63
  success: "success";
64
+ pending: "pending";
64
65
  Pending: "Pending";
65
66
  Current: "Current";
66
67
  Passed: "Passed";
@@ -71,7 +72,6 @@ export declare const manualRunSchema: z.ZodObject<{
71
72
  Fail: "Fail";
72
73
  Success: "Success";
73
74
  pass: "pass";
74
- pending: "pending";
75
75
  blocked: "blocked";
76
76
  }>;
77
77
  group_title: z.ZodOptional<z.ZodString>;
@@ -143,6 +143,7 @@ export declare const manualCanvasSchema: z.ZodObject<{
143
143
  failed: "failed";
144
144
  skipped: "skipped";
145
145
  success: "success";
146
+ pending: "pending";
146
147
  Pending: "Pending";
147
148
  Current: "Current";
148
149
  Passed: "Passed";
@@ -153,7 +154,6 @@ export declare const manualCanvasSchema: z.ZodObject<{
153
154
  Fail: "Fail";
154
155
  Success: "Success";
155
156
  pass: "pass";
156
- pending: "pending";
157
157
  blocked: "blocked";
158
158
  }>;
159
159
  group_title: z.ZodOptional<z.ZodString>;
@@ -0,0 +1,3 @@
1
+ import type { Page } from 'playwright';
2
+ import type { FlowstrideConfig } from '../types';
3
+ export declare function accessibility(page: Page, config: FlowstrideConfig, action: string, input: unknown): Promise<string | undefined>;
@@ -0,0 +1,68 @@
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.accessibility = accessibility;
7
+ const zod_1 = require("zod");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const playwright_1 = __importDefault(require("@axe-core/playwright"));
11
+ const validation_1 = require("./validation");
12
+ const projectPaths_1 = require("../utils/projectPaths");
13
+ async function accessibility(page, config, action, input) {
14
+ if (action === 'scan') {
15
+ const data = zod_1.z.object({ name: validation_1.name.default('accessibility'), include: zod_1.z.array(zod_1.z.string()).optional(), exclude: zod_1.z.array(zod_1.z.string()).optional(), tags: zod_1.z.array(zod_1.z.string()).optional(), rules: zod_1.z.array(zod_1.z.string()).optional(), disableRules: zod_1.z.array(zod_1.z.string()).optional(), impacts: zod_1.z.array(zod_1.z.enum(['minor', 'moderate', 'serious', 'critical'])).optional(), maxViolations: zod_1.z.number().int().nonnegative().default(0) }).strict().parse(input);
16
+ let builder = new playwright_1.default({ page });
17
+ for (const selector of data.include || [])
18
+ builder = builder.include(selector);
19
+ for (const selector of data.exclude || [])
20
+ builder = builder.exclude(selector);
21
+ if (data.tags)
22
+ builder = builder.withTags(data.tags);
23
+ if (data.rules)
24
+ builder = builder.withRules(data.rules);
25
+ if (data.disableRules)
26
+ builder = builder.disableRules(data.disableRules);
27
+ const result = await builder.analyze();
28
+ const violations = result.violations.filter(item => !data.impacts || item.impact && data.impacts.includes(item.impact));
29
+ const file = (0, validation_1.evidencePath)(config, `${data.name}-${Date.now()}.json`);
30
+ // Reports may contain application content. They remain in local evidence.
31
+ node_fs_1.default.writeFileSync(file, JSON.stringify({ ...result, violations }, null, 2), { mode: 0o600 });
32
+ if (violations.length > data.maxViolations)
33
+ throw new Error(`Accessibility assertion failed: ${violations.length} violations (${violations.map(item => item.id).join(', ')}). Report: ${file}`);
34
+ return file;
35
+ }
36
+ if (action === 'snapshot') {
37
+ const data = zod_1.z.object({ name: validation_1.name, selector: zod_1.z.string().default('body'), baseline: zod_1.z.string().optional(), update: zod_1.z.boolean().default(false), timeout: validation_1.positive.optional() }).strict().parse(input);
38
+ const snapshot = await page.locator(data.selector).ariaSnapshot({ timeout: data.timeout ?? config.timeout });
39
+ const file = (0, validation_1.evidencePath)(config, `${data.name}-${Date.now()}.aria.yml`);
40
+ node_fs_1.default.writeFileSync(file, snapshot, { mode: 0o600 });
41
+ if (!data.baseline) {
42
+ if (data.update)
43
+ throw new Error('Choose a baseline path.');
44
+ return file;
45
+ }
46
+ const baseline = (0, projectPaths_1.projectPath)(config.projectRoot || process.cwd(), data.baseline);
47
+ if (data.update || config.updateBaselines) {
48
+ if (config.ci || process.env.CI && process.env.CI !== 'false')
49
+ throw new Error('Baseline updates are not allowed in CI.');
50
+ if (!config.updateBaselines)
51
+ throw new Error('Baseline writing requires --update-baselines.');
52
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(baseline), { recursive: true });
53
+ node_fs_1.default.writeFileSync(baseline, snapshot, { mode: 0o600 });
54
+ }
55
+ else {
56
+ const expected = (0, validation_1.readProjectFile)(config, baseline).toString('utf8').replace(/\r\n/g, '\n').trim();
57
+ if (expected !== snapshot.trim())
58
+ throw new Error(`ARIA baseline differs. Review the captured snapshot before updating: ${file}`);
59
+ }
60
+ return file;
61
+ }
62
+ if (action === 'match') {
63
+ const data = zod_1.z.object({ selector: zod_1.z.string().default('body'), yaml: zod_1.z.string().min(1), timeout: validation_1.positive.optional() }).strict().parse(input);
64
+ await (0, validation_1.poll)(async () => (await page.locator(data.selector).ariaSnapshot()).trim() === data.yaml.replace(/\r\n/g, '\n').trim(), data.timeout ?? config.timeout, 'ARIA snapshot differs');
65
+ return;
66
+ }
67
+ throw new Error(`Unknown accessibility action: ${action}`);
68
+ }
@@ -0,0 +1,2 @@
1
+ import type { Page } from 'playwright';
2
+ export declare function assertBrowser(page: Page, input: unknown, defaultTimeout: number): Promise<void>;
@@ -0,0 +1,140 @@
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.assertBrowser = assertBrowser;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const zod_1 = require("zod");
9
+ const validation_1 = require("./validation");
10
+ const node_util_1 = require("node:util");
11
+ let accessibilityBundle;
12
+ async function installAccessibilityNames(page) {
13
+ if (await page.evaluate(() => !!window.__FLOWSTRIDE_ACCESSIBILITY_NAMES__))
14
+ return;
15
+ accessibilityBundle ||= (async () => {
16
+ const { build } = await import('vite');
17
+ const result = await build({ configFile: false, root: node_path_1.default.dirname(require.resolve('dom-accessibility-api')), envDir: false, logLevel: 'silent', build: { write: false, minify: true, emptyOutDir: false,
18
+ lib: { entry: require.resolve('dom-accessibility-api'), formats: ['iife'], name: '__FLOWSTRIDE_ACCESSIBILITY_NAMES__' } } });
19
+ const code = (Array.isArray(result) ? result : [result]).flatMap(bundle => bundle.output).find((file) => file.type === 'chunk').code;
20
+ return code + '\nwindow.__FLOWSTRIDE_ACCESSIBILITY_NAMES__=__FLOWSTRIDE_ACCESSIBILITY_NAMES__;';
21
+ })();
22
+ const source = await accessibilityBundle;
23
+ for (const frame of page.frames())
24
+ await frame.evaluate(source);
25
+ }
26
+ const schema = zod_1.z.object({ selector: zod_1.z.string().optional(), role: zod_1.z.string().optional(), name: zod_1.z.string().optional(), frame: zod_1.z.string().optional(),
27
+ condition: zod_1.z.enum(['visible', 'hidden', 'attached', 'enabled', 'disabled', 'editable', 'checked', 'empty', 'focused', 'text', 'containsText', 'value', 'values', 'count', 'attribute', 'css', 'id', 'property', 'accessibleName', 'accessibleDescription', 'inViewport', 'url', 'title', 'localStorage', 'sessionStorage']),
28
+ value: zod_1.z.unknown().optional(), attribute: zod_1.z.string().optional(), property: zod_1.z.string().optional(), key: zod_1.z.string().optional(),
29
+ matches: zod_1.z.string().optional(), flags: zod_1.z.string().regex(/^[imsu]*$/).optional(), not: zod_1.z.boolean().default(false), timeout: validation_1.positive.optional(),
30
+ }).strict();
31
+ async function assertBrowser(page, input, defaultTimeout) {
32
+ const data = schema.parse(input), timeout = data.timeout ?? defaultTimeout;
33
+ const expected = data.matches !== undefined ? new RegExp(data.matches, data.flags) : data.value;
34
+ const equals = (actual) => expected instanceof RegExp ? typeof actual === 'string' && expected.test(actual) : (0, node_util_1.isDeepStrictEqual)(actual, expected);
35
+ const scope = data.frame ? page.frameLocator(data.frame) : page;
36
+ const elementCondition = !['url', 'title', 'localStorage', 'sessionStorage'].includes(data.condition);
37
+ if (elementCondition && !!data.selector === !!data.role)
38
+ throw new Error('Choose one selector or role.');
39
+ const locator = data.role ? scope.getByRole(data.role, { name: data.name, exact: true }) : scope.locator(data.selector || 'body');
40
+ const booleanConditions = ['visible', 'hidden', 'attached', 'enabled', 'disabled', 'editable', 'checked', 'empty', 'focused', 'inViewport'];
41
+ if (!booleanConditions.includes(data.condition) && data.condition !== 'attribute' && expected === undefined)
42
+ throw new Error('This assertion requires value or matches.');
43
+ if (data.condition === 'attribute' && !data.attribute || ['css', 'property'].includes(data.condition) && !data.property || ['localStorage', 'sessionStorage'].includes(data.condition) && !data.key)
44
+ throw new Error('Assertion requires an attribute, property, or storage key.');
45
+ if (data.condition === 'count' && (!Number.isSafeInteger(expected) || expected < 0))
46
+ throw new Error('Count requires a nonnegative integer.');
47
+ await (0, validation_1.poll)(async () => {
48
+ if (['accessibleName', 'accessibleDescription'].includes(data.condition))
49
+ await installAccessibilityNames(page);
50
+ let matched;
51
+ switch (data.condition) {
52
+ case 'url':
53
+ matched = equals(page.url());
54
+ break;
55
+ case 'title':
56
+ matched = equals(await page.title());
57
+ break;
58
+ case 'localStorage':
59
+ case 'sessionStorage':
60
+ matched = equals(await page.evaluate(({ kind, key }) => window[kind].getItem(key), { kind: data.condition, key: data.key }));
61
+ break;
62
+ case 'count':
63
+ matched = equals(await locator.count());
64
+ break;
65
+ case 'attached':
66
+ matched = await locator.count() > 0;
67
+ break;
68
+ case 'hidden':
69
+ matched = await locator.count() === 0 || await locator.isHidden();
70
+ break;
71
+ case 'visible':
72
+ matched = await locator.isVisible();
73
+ break;
74
+ default: {
75
+ if (await locator.count() !== 1)
76
+ return false;
77
+ switch (data.condition) {
78
+ case 'enabled':
79
+ matched = await locator.isEnabled();
80
+ break;
81
+ case 'disabled':
82
+ matched = await locator.isDisabled();
83
+ break;
84
+ case 'editable':
85
+ matched = await locator.isEditable();
86
+ break;
87
+ case 'checked':
88
+ matched = await locator.isChecked();
89
+ break;
90
+ case 'focused':
91
+ matched = await locator.evaluate(element => element === element.ownerDocument.activeElement);
92
+ break;
93
+ case 'empty':
94
+ matched = await locator.evaluate(element => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent || '').length === 0);
95
+ break;
96
+ case 'text':
97
+ matched = equals((await locator.textContent())?.replace(/\s+/g, ' ').trim());
98
+ break;
99
+ case 'containsText': {
100
+ const text = (await locator.textContent() || '').replace(/\s+/g, ' ').trim();
101
+ matched = expected instanceof RegExp ? expected.test(text) : text.includes(String(expected));
102
+ break;
103
+ }
104
+ case 'value':
105
+ matched = equals(await locator.inputValue());
106
+ break;
107
+ case 'values':
108
+ matched = equals(await locator.evaluate(element => Array.from(element.selectedOptions).map(option => option.value)));
109
+ break;
110
+ case 'attribute': {
111
+ const value = await locator.getAttribute(data.attribute);
112
+ matched = expected === undefined ? value !== null : equals(value);
113
+ break;
114
+ }
115
+ case 'id':
116
+ matched = equals(await locator.getAttribute('id'));
117
+ break;
118
+ case 'css':
119
+ matched = equals(await locator.evaluate((element, key) => getComputedStyle(element).getPropertyValue(key), data.property));
120
+ break;
121
+ case 'property':
122
+ matched = equals(await locator.evaluate((element, key) => element[key], data.property));
123
+ break;
124
+ case 'accessibleName':
125
+ case 'accessibleDescription':
126
+ matched = equals(await locator.evaluate((element, condition) => {
127
+ const api = element.ownerDocument.defaultView.__FLOWSTRIDE_ACCESSIBILITY_NAMES__;
128
+ return condition === 'accessibleName' ? api.computeAccessibleName(element) : api.computeAccessibleDescription(element);
129
+ }, data.condition));
130
+ break;
131
+ case 'inViewport':
132
+ matched = await locator.evaluate(element => { const box = element.getBoundingClientRect(); return box.width > 0 && box.height > 0 && box.bottom > 0 && box.right > 0 && box.top < innerHeight && box.left < innerWidth; });
133
+ break;
134
+ default: throw new Error('Unknown assertion condition.');
135
+ }
136
+ }
137
+ }
138
+ return data.not ? !matched : matched;
139
+ }, timeout, `${data.condition} ${data.selector || data.role || data.key || ''}`);
140
+ }
@@ -0,0 +1,3 @@
1
+ import type { FlowstrideConfig } from '../types';
2
+ /** Old Cloud engines remain usable for their existing commands. */
3
+ export declare function needsNativeBrowserProtocol(target: string, config: FlowstrideConfig): boolean;
@@ -0,0 +1,16 @@
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.needsNativeBrowserProtocol = needsNativeBrowserProtocol;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const lexer_1 = require("../parser/lexer");
9
+ const projectPaths_1 = require("../utils/projectPaths");
10
+ /** Old Cloud engines remain usable for their existing commands. */
11
+ function needsNativeBrowserProtocol(target, config) {
12
+ if (config.browser && config.browser !== 'chromium' || Object.keys(config.browserOptions || {}).length ||
13
+ config.retries || config.failOnFlaky || config.trace || config.har || config.domSnapshots || config.shard)
14
+ return true;
15
+ return (0, projectPaths_1.discoverFlows)(target, config.projectRoot || process.cwd()).some(file => new lexer_1.Lexer(node_fs_1.default.readFileSync(file, 'utf8')).tokenise().some(token => token.type === lexer_1.TokenType.ALIAS_COMMAND && /^flow\.(browser|assert|network|download|clock|stub|trace|har|a11y|component)\./i.test(token.value)));
16
+ }
@@ -0,0 +1,12 @@
1
+ import type { Page } from 'playwright';
2
+ import type { FlowstrideConfig } from '../types';
3
+ export declare class NativeComponents {
4
+ private config;
5
+ private server?;
6
+ private page?;
7
+ private mounted;
8
+ private framework?;
9
+ constructor(config: FlowstrideConfig);
10
+ execute(page: Page, action: string, input: unknown): Promise<void>;
11
+ dispose(): Promise<void>;
12
+ }
@@ -0,0 +1,122 @@
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.NativeComponents = void 0;
7
+ const zod_1 = require("zod");
8
+ const node_module_1 = require("node:module");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const projectPaths_1 = require("../utils/projectPaths");
11
+ const node_crypto_1 = require("node:crypto");
12
+ const schema = zod_1.z.object({ module: zod_1.z.string().min(1), framework: zod_1.z.enum(['vanilla', 'react', 'vue']), export: zod_1.z.string().regex(/^[a-zA-Z_$][\w$]*$/).default('default'), props: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).default({}), css: zod_1.z.array(zod_1.z.string()).default([]) }).strict();
13
+ class NativeComponents {
14
+ config;
15
+ server;
16
+ page;
17
+ mounted = false;
18
+ framework;
19
+ constructor(config) {
20
+ this.config = config;
21
+ }
22
+ async execute(page, action, input) {
23
+ if (action === 'mount') {
24
+ const data = schema.parse(input);
25
+ await this.dispose();
26
+ const root = this.config.projectRoot || process.cwd(), module = (0, projectPaths_1.projectPath)(root, data.module);
27
+ const css = data.css.map(file => '/' + node_path_1.default.relative(root, (0, projectPaths_1.projectPath)(root, file)).split(node_path_1.default.sep).join('/'));
28
+ const projectRequire = (0, node_module_1.createRequire)(node_path_1.default.join(root, 'package.json'));
29
+ // Resolve project framework runtimes before starting any server.
30
+ if (data.framework === 'react') {
31
+ projectRequire.resolve('react');
32
+ projectRequire.resolve('react-dom/client');
33
+ }
34
+ if (data.framework === 'vue')
35
+ projectRequire.resolve('vue');
36
+ const { createServer } = await import('vite');
37
+ const plugins = [];
38
+ if (data.framework === 'react') {
39
+ const { default: react } = await import('@vitejs/plugin-react');
40
+ plugins.push(react({ jsxRuntime: 'automatic' }));
41
+ }
42
+ if (data.framework === 'vue') {
43
+ const { default: vue } = await import('@vitejs/plugin-vue');
44
+ plugins.push(vue());
45
+ }
46
+ const entry = `/__flowstride_component_${(0, node_crypto_1.randomUUID)()}`;
47
+ const moduleURL = '/' + node_path_1.default.relative(root, module).split(node_path_1.default.sep).join('/');
48
+ const importComponent = `import { ${data.export} as component } from ${JSON.stringify(moduleURL)};`;
49
+ const styles = css.map(file => `import ${JSON.stringify(file)};`).join('\n');
50
+ let kernel;
51
+ if (data.framework === 'react')
52
+ kernel = `import React from 'react'; import {createRoot} from 'react-dom/client'; const root = createRoot(target); export function update(props){root.render(React.createElement(component,props));} export function unmount(){root.unmount();}`;
53
+ else if (data.framework === 'vue')
54
+ kernel = `import {createApp,h,reactive} from 'vue'; const props=reactive({}); const app=createApp({render:()=>h(component,props)}); app.mount(target); export function update(next){for(const key of Object.keys(props))delete props[key];Object.assign(props,next);} export function unmount(){app.unmount();}`;
55
+ else
56
+ kernel = `let current; export function update(props){current?.unmount?.();target.replaceChildren();current=component(target,props);if(current instanceof Node)target.appendChild(current);} export function unmount(){current?.unmount?.();target.replaceChildren();}`;
57
+ const source = `${importComponent}\n${styles}\nconst target=document.getElementById('flowstride-component-root');\n${kernel}`;
58
+ plugins.push({ name: 'flowstride-component-module', resolveId(id) { if (id === entry + '.js')
59
+ return '\0' + entry; }, load(id) { if (id === '\0' + entry)
60
+ return source; } });
61
+ plugins.push({ name: 'flowstride-component-entry', configureServer(server) {
62
+ server.middlewares.use((req, res, next) => {
63
+ const host = `127.0.0.1:${server.httpServer.address()?.port}`;
64
+ if (req.headers.host !== host || req.headers.origin && req.headers.origin !== `http://${host}`) {
65
+ res.statusCode = 403;
66
+ res.end('Forbidden');
67
+ return;
68
+ }
69
+ if (req.url?.split('?')[0] !== entry)
70
+ return next();
71
+ res.setHeader('Content-Type', 'text/html');
72
+ res.setHeader('Cache-Control', 'no-store');
73
+ res.end('<!doctype html><html><head><title>Flowstride component test</title></head><body><div id="flowstride-component-root"></div></body></html>');
74
+ });
75
+ } });
76
+ this.server = await createServer({ configFile: false, root, plugins, logLevel: 'error', cacheDir: (0, projectPaths_1.projectPath)(root, '.flowstride/component-cache'),
77
+ server: { host: '127.0.0.1', port: 0, cors: false, hmr: false, watch: null, fs: { strict: true, allow: [root], deny: ['.env', '.env.*', '**/.flowstride/**', '**/.git/**', '*.{crt,pem,key}'] } },
78
+ optimizeDeps: { noDiscovery: true, include: data.framework === 'react' ? ['react', 'react-dom/client'] : data.framework === 'vue' ? ['vue'] : [] }, appType: 'custom' });
79
+ try {
80
+ await this.server.listen();
81
+ const address = this.server.httpServer.address();
82
+ await page.goto(`http://127.0.0.1:${address.port}${entry}`);
83
+ await page.evaluate(async ({ entry, props }) => {
84
+ const mod = await import(entry + '.js');
85
+ window.__FLOWSTRIDE_COMPONENT__ = mod;
86
+ mod.update(props);
87
+ }, { entry, props: data.props });
88
+ this.page = page;
89
+ this.mounted = true;
90
+ this.framework = data.framework;
91
+ }
92
+ catch (error) {
93
+ await this.dispose();
94
+ throw error;
95
+ }
96
+ return;
97
+ }
98
+ if (!this.mounted || page !== this.page)
99
+ throw new Error('Mount a component on this page first.');
100
+ if (action === 'update') {
101
+ const data = zod_1.z.object({ props: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }).strict().parse(input);
102
+ await page.evaluate(props => window.__FLOWSTRIDE_COMPONENT__.update(props), data.props);
103
+ return;
104
+ }
105
+ if (action === 'unmount') {
106
+ zod_1.z.object({}).strict().parse(input);
107
+ await page.evaluate(() => window.__FLOWSTRIDE_COMPONENT__.unmount());
108
+ this.mounted = false;
109
+ return;
110
+ }
111
+ throw new Error(`Unknown component action: ${action}`);
112
+ }
113
+ async dispose() {
114
+ if (this.mounted && this.page && !this.page.isClosed())
115
+ await this.page.evaluate(() => window.__FLOWSTRIDE_COMPONENT__?.unmount()).catch(() => { });
116
+ this.mounted = false;
117
+ this.page = undefined;
118
+ await this.server?.close();
119
+ this.server = undefined;
120
+ }
121
+ }
122
+ exports.NativeComponents = NativeComponents;
@@ -0,0 +1,7 @@
1
+ import type { FlowstrideConfig } from '../types';
2
+ /** The dashboard has its own browser; it never shares test cookies or hooks. */
3
+ export declare class DashboardBrowser {
4
+ private browser?;
5
+ open(url: string, engine?: FlowstrideConfig['browser']): Promise<void>;
6
+ close(): Promise<void>;
7
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DashboardBrowser = void 0;
4
+ const playwright_1 = require("playwright");
5
+ /** The dashboard has its own browser; it never shares test cookies or hooks. */
6
+ class DashboardBrowser {
7
+ browser;
8
+ async open(url, engine = 'chromium') {
9
+ if (engine === 'chromium') {
10
+ const { default: open } = await import('open');
11
+ await open(url);
12
+ return;
13
+ }
14
+ if (engine !== 'firefox' && engine !== 'webkit')
15
+ throw new Error('Unknown dashboard browser.');
16
+ try {
17
+ this.browser = await (engine === 'firefox' ? playwright_1.firefox : playwright_1.webkit).launch({ headless: false });
18
+ const page = await this.browser.newPage();
19
+ await page.goto(url);
20
+ }
21
+ catch (error) {
22
+ await this.close();
23
+ throw error;
24
+ }
25
+ }
26
+ async close() {
27
+ const browser = this.browser;
28
+ this.browser = undefined;
29
+ await browser?.close();
30
+ }
31
+ }
32
+ exports.DashboardBrowser = DashboardBrowser;