flowstride 1.0.19 → 1.0.20-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NATIVE_CI.md +179 -0
- package/dist/adapters/web/playwright.d.ts +1 -0
- package/dist/adapters/web/playwright.js +24 -18
- package/dist/ci/config.d.ts +3 -0
- package/dist/ci/config.js +77 -0
- package/dist/ci/deadline.d.ts +2 -0
- package/dist/ci/deadline.js +16 -0
- package/dist/ci/init.d.ts +1 -0
- package/dist/ci/init.js +21 -0
- package/dist/ci/runtime.d.ts +19 -0
- package/dist/ci/runtime.js +203 -0
- package/dist/cli/commands/run.js +17 -35
- package/dist/cli/index.js +78 -54
- package/dist/cli/manual_server/workspace_server.js +44 -32
- package/dist/cli/utils/cloudSession.d.ts +7 -0
- package/dist/cli/utils/cloudSession.js +37 -0
- package/dist/cli/utils/proInstaller.d.ts +5 -0
- package/dist/cli/utils/proInstaller.js +141 -0
- package/dist/game/bridge.d.ts +13 -0
- package/dist/game/bridge.js +15 -0
- package/dist/game/sdk.d.ts +27 -0
- package/dist/game/sdk.js +256 -0
- package/dist/game/three-bridge.d.ts +18 -0
- package/dist/game/three-bridge.js +59 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/manual/security.d.ts +183 -0
- package/dist/manual/security.js +106 -0
- package/dist/runner/data_generators.d.ts +1 -0
- package/dist/runner/data_generators.js +3 -0
- package/dist/runner/dom_stripper.js +3 -0
- package/dist/runner/flow_orchestrator.d.ts +5 -1
- package/dist/runner/flow_orchestrator.js +34 -19
- package/dist/runner/flow_worker.d.ts +1 -0
- package/dist/runner/flow_worker.js +56 -16
- package/dist/runner/persistent_vault.js +2 -1
- package/dist/runner/plugin_manager.d.ts +1 -0
- package/dist/runner/plugin_manager.js +35 -1
- package/dist/security/localDashboard.d.ts +2 -0
- package/dist/security/localDashboard.js +22 -0
- package/dist/security/redaction.d.ts +4 -0
- package/dist/security/redaction.js +49 -0
- package/dist/types/index.d.ts +43 -0
- package/dist/utils/cloud.d.ts +6 -0
- package/dist/utils/cloud.js +33 -0
- package/dist/utils/projectPaths.d.ts +11 -0
- package/dist/utils/projectPaths.js +90 -0
- package/examples/native-game/flows/canvas.flow +13 -0
- package/examples/native-game/flowstride.config.json +12 -0
- package/examples/native-game/server.mjs +12 -0
- package/package.json +27 -11
- package/src/ui/dist/assets/index-DVwp0K-J.js +462 -0
- package/src/ui/dist/index.html +1 -1
- package/src/ui/dist/assets/index-DIC22ZK_.js +0 -638
|
@@ -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 projectPaths_1 = require("../utils/projectPaths");
|
|
41
|
+
const redaction_1 = require("../security/redaction");
|
|
40
42
|
const fs = __importStar(require("fs"));
|
|
41
43
|
const path = __importStar(require("path"));
|
|
42
44
|
const chalk_1 = __importDefault(require("chalk"));
|
|
@@ -55,6 +57,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
55
57
|
pluginManager;
|
|
56
58
|
isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
|
|
57
59
|
isAborted = false;
|
|
60
|
+
bailTriggered = false;
|
|
58
61
|
isDebugHalted = false;
|
|
59
62
|
currentStepContext = "Initializing...";
|
|
60
63
|
currentFileName = "System";
|
|
@@ -76,7 +79,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
76
79
|
this.pluginManager = pluginManager;
|
|
77
80
|
}
|
|
78
81
|
broadcast(type, payload) {
|
|
79
|
-
this.
|
|
82
|
+
const secrets = [this.config.machineToken || '', ...(this.dataGen?.sensitiveValues?.() || []), ...Object.entries(process.env).filter(([key]) => /password|token|secret|api.?key/i.test(key)).map(([, value]) => String(value)), ...Object.entries(this.localVariables).filter(([key]) => /password|passcode|token|secret|otp|api.?key/i.test(key)).map(([, value]) => String(value))];
|
|
83
|
+
this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
|
|
80
84
|
}
|
|
81
85
|
emitLog(type, status, summary, details, customId, overrideFileName) {
|
|
82
86
|
const id = customId || Math.random().toString(36).substr(2, 9);
|
|
@@ -362,8 +366,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
362
366
|
}
|
|
363
367
|
this.activeAudioPath = undefined;
|
|
364
368
|
this.isAborted = false;
|
|
369
|
+
this.bailTriggered = false;
|
|
365
370
|
this.isDebugHalted = false;
|
|
366
371
|
const sigintHandler = async () => {
|
|
372
|
+
if (this.config.ci) {
|
|
373
|
+
this.abort();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
367
376
|
if (!this.isSilent) {
|
|
368
377
|
console.log(chalk_1.default.yellow("\n[Flowstride]: Process interrupted. Gracefully shutting down..."));
|
|
369
378
|
}
|
|
@@ -373,7 +382,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
373
382
|
await this.web.shutdown();
|
|
374
383
|
}
|
|
375
384
|
catch (e) { }
|
|
376
|
-
|
|
385
|
+
if (!this.config.ci)
|
|
386
|
+
process.exit(0);
|
|
377
387
|
};
|
|
378
388
|
process.once("SIGINT", sigintHandler);
|
|
379
389
|
try {
|
|
@@ -382,7 +392,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
382
392
|
const audioMatch = content.match(/(?:flow\.)?injectAudio\s+["']([^"']+)["']/i);
|
|
383
393
|
if (audioMatch && audioMatch[1]) {
|
|
384
394
|
const rawPath = audioMatch[1];
|
|
385
|
-
const absolutePath =
|
|
395
|
+
const absolutePath = (0, projectPaths_1.projectPath)(process.cwd(), rawPath);
|
|
386
396
|
if (!fs.existsSync(absolutePath)) {
|
|
387
397
|
throw new Error(`Pre-launch initialization failed: Audio file not found at ${absolutePath}`);
|
|
388
398
|
}
|
|
@@ -391,12 +401,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
391
401
|
}
|
|
392
402
|
}
|
|
393
403
|
for (const filePath of filesToProcess) {
|
|
394
|
-
if (this.isAborted || this.isDebugHalted)
|
|
404
|
+
if (this.isAborted || this.isDebugHalted || this.bailTriggered)
|
|
395
405
|
break;
|
|
396
406
|
const fileStartTime = Date.now();
|
|
397
407
|
try {
|
|
398
|
-
const fileName =
|
|
408
|
+
const fileName = (0, projectPaths_1.flowIdentity)(filePath, this.config.flowRoot);
|
|
399
409
|
this.currentFileName = fileName;
|
|
410
|
+
this.config.testFile = fileName;
|
|
400
411
|
this.localVariables = {};
|
|
401
412
|
this.localBrowserSessions = {};
|
|
402
413
|
this.api.resetState();
|
|
@@ -445,14 +456,14 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
445
456
|
});
|
|
446
457
|
}
|
|
447
458
|
for (const f of features) {
|
|
448
|
-
if (this.isAborted || this.isDebugHalted)
|
|
459
|
+
if (this.isAborted || this.isDebugHalted || this.bailTriggered)
|
|
449
460
|
break;
|
|
450
461
|
if (f.scenarios.length === 0)
|
|
451
462
|
continue;
|
|
452
463
|
if (!this.isSilent)
|
|
453
464
|
console.log(chalk_1.default.magenta.bold(` Feature: ${f.name}`));
|
|
454
465
|
for (const s of f.scenarios) {
|
|
455
|
-
if (this.isAborted || this.isDebugHalted)
|
|
466
|
+
if (this.isAborted || this.isDebugHalted || this.bailTriggered)
|
|
456
467
|
break;
|
|
457
468
|
if (!this.isSilent)
|
|
458
469
|
console.log(chalk_1.default.cyan(` Scenario: ${s.name}`));
|
|
@@ -461,7 +472,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
461
472
|
fileName: this.currentFileName,
|
|
462
473
|
});
|
|
463
474
|
await this.runScenario(s, f.declarations);
|
|
464
|
-
if (this.isDebugHalted)
|
|
475
|
+
if (this.isDebugHalted || this.bailTriggered)
|
|
465
476
|
break;
|
|
466
477
|
await this.web
|
|
467
478
|
.getPage()
|
|
@@ -471,20 +482,24 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
471
482
|
.getPage()
|
|
472
483
|
.waitForLoadState("networkidle", { timeout: 4000 })
|
|
473
484
|
.catch(() => { });
|
|
474
|
-
|
|
485
|
+
if (!this.config.ci)
|
|
486
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
475
487
|
}
|
|
476
488
|
}
|
|
477
489
|
if (this.isAborted)
|
|
478
490
|
break;
|
|
479
491
|
if (!this.isSilent)
|
|
480
492
|
console.log(chalk_1.default.gray("\n[Execution]: Finalizing browser actions..."));
|
|
481
|
-
|
|
493
|
+
if (!this.config.ci)
|
|
494
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
482
495
|
try {
|
|
483
|
-
const baseName = this.currentFileName.replace(
|
|
496
|
+
const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
|
|
484
497
|
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
485
498
|
if (!fs.existsSync(mockCloudDir))
|
|
486
499
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
487
500
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
501
|
+
for (const message of this.web.pageErrors || [])
|
|
502
|
+
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
488
503
|
await this.web.shutdown(targetVideoPath);
|
|
489
504
|
if (!this.isSilent)
|
|
490
505
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -501,11 +516,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
501
516
|
if (!this.isSilent)
|
|
502
517
|
console.error(chalk_1.default.red(`\n[Execution]: Failed processing ${this.currentFileName}: ${e.message}`));
|
|
503
518
|
try {
|
|
504
|
-
const baseName = this.currentFileName.replace(
|
|
519
|
+
const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
|
|
505
520
|
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
506
521
|
if (!fs.existsSync(mockCloudDir))
|
|
507
522
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
508
523
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
524
|
+
for (const message of this.web.pageErrors || [])
|
|
525
|
+
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
509
526
|
await this.web.shutdown(targetVideoPath);
|
|
510
527
|
if (!this.isSilent)
|
|
511
528
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -518,7 +535,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
518
535
|
});
|
|
519
536
|
}
|
|
520
537
|
}
|
|
521
|
-
if (!this.isSilent && !this.isAborted) {
|
|
538
|
+
if (!this.config.ci && !this.isSilent && !this.isAborted) {
|
|
522
539
|
if (this.isDebugHalted) {
|
|
523
540
|
console.log(chalk_1.default.cyan("\n[Execution]: Test suite halted by debug flag."));
|
|
524
541
|
}
|
|
@@ -528,10 +545,21 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
528
545
|
}
|
|
529
546
|
}
|
|
530
547
|
finally {
|
|
548
|
+
if (this.config.ci && this.isAborted) {
|
|
549
|
+
try {
|
|
550
|
+
const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
|
|
551
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
552
|
+
await this.web.shutdown(path.join(directory, (0, projectPaths_1.videoFileName)(this.currentFileName)));
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
this.broadcast('WORKER_ERROR', { message: error.message });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
531
558
|
process.removeListener("SIGINT", sigintHandler);
|
|
532
559
|
}
|
|
533
560
|
}
|
|
534
561
|
async runScenario(scenario, globalDeclarations = []) {
|
|
562
|
+
let hasScenarioFailed = false;
|
|
535
563
|
for (const step of scenario.steps) {
|
|
536
564
|
if (this.isAborted)
|
|
537
565
|
break;
|
|
@@ -543,6 +571,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
543
571
|
step.payload = this.resolveTemplateData(step.payload);
|
|
544
572
|
this.currentStepContext = `${blockName} ${blockDesc}`;
|
|
545
573
|
const isOpt = step.payload?.optional;
|
|
574
|
+
if (hasScenarioFailed) {
|
|
575
|
+
this.broadcast('STEP_ADD', { id: step.id, type: blockName, description: blockDesc, command: step.action, status: 'not_tested', fileName: this.currentFileName });
|
|
576
|
+
this.broadcast('STEP_UPDATE', { id: step.id, status: 'not_tested', fileName: this.currentFileName });
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
546
579
|
const mockSpinner = {
|
|
547
580
|
text: "",
|
|
548
581
|
start() {
|
|
@@ -671,7 +704,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
671
704
|
screenshot: stepContext.screenshot,
|
|
672
705
|
fileName: this.currentFileName,
|
|
673
706
|
});
|
|
674
|
-
|
|
707
|
+
hasScenarioFailed = true;
|
|
708
|
+
if (this.config.bail && !this.bailTriggered) {
|
|
709
|
+
this.bailTriggered = true;
|
|
710
|
+
this.broadcast('BAIL', { fileName: this.currentFileName, id: step.id });
|
|
711
|
+
}
|
|
675
712
|
}
|
|
676
713
|
}
|
|
677
714
|
}
|
|
@@ -1167,11 +1204,12 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1167
1204
|
const sharedId = Math.random().toString(36).substr(2, 9);
|
|
1168
1205
|
const urlObj = new URL(request.url());
|
|
1169
1206
|
const endpointName = urlObj.pathname.split("/").pop() || urlObj.pathname;
|
|
1170
|
-
const
|
|
1207
|
+
const requestHeaders = await request.allHeaders().catch(() => request.headers());
|
|
1208
|
+
const curlString = this.web.synthesizeCurl(request.method(), request.url(), requestHeaders, rawPostData);
|
|
1171
1209
|
this.emitLog("REST", response.status() < 400 ? "success" : "error", `${request.method()} ${endpointName}`, {
|
|
1172
1210
|
method: request.method(),
|
|
1173
1211
|
url: request.url(),
|
|
1174
|
-
requestHeaders:
|
|
1212
|
+
requestHeaders: requestHeaders,
|
|
1175
1213
|
requestBody: requestBody,
|
|
1176
1214
|
responseStatus: response.status(),
|
|
1177
1215
|
responseHeaders: response.headers(),
|
|
@@ -1196,6 +1234,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1196
1234
|
});
|
|
1197
1235
|
}
|
|
1198
1236
|
async startStreaming() {
|
|
1237
|
+
if (this.config.ci)
|
|
1238
|
+
return;
|
|
1199
1239
|
const page = this.web.getPage();
|
|
1200
1240
|
if (this.currentCdpSession) {
|
|
1201
1241
|
await this.currentCdpSession.detach().catch(() => { });
|
|
@@ -34,13 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.PersistentVault = void 0;
|
|
37
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
37
38
|
const fs = __importStar(require("fs"));
|
|
38
39
|
const path = __importStar(require("path"));
|
|
39
40
|
class PersistentVault {
|
|
40
41
|
vaultPath;
|
|
41
42
|
data = {};
|
|
42
43
|
constructor(projectRoot = process.cwd()) {
|
|
43
|
-
this.vaultPath = path.join(projectRoot, ".flowstride", "vault.json");
|
|
44
|
+
this.vaultPath = (0, projectPaths_1.projectPath)(projectRoot, path.join(projectRoot, ".flowstride", "vault.json"));
|
|
44
45
|
this.initialiseVault();
|
|
45
46
|
}
|
|
46
47
|
initialiseVault() {
|
|
@@ -8,6 +8,7 @@ export declare class PluginManager {
|
|
|
8
8
|
private store;
|
|
9
9
|
private apiProxy;
|
|
10
10
|
private dbProxy;
|
|
11
|
+
private game;
|
|
11
12
|
constructor(config: FlowstrideConfig, web: WebAdapter, store: TemporaryStore, apiProxy: ApiProxy);
|
|
12
13
|
execute(actionName: string, args: any, saveAs?: string): Promise<any>;
|
|
13
14
|
}
|
|
@@ -32,8 +32,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
35
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
39
|
exports.PluginManager = void 0;
|
|
40
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
41
|
+
const deadline_1 = require("../ci/deadline");
|
|
42
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
43
|
+
const sdk_1 = require("../game/sdk");
|
|
37
44
|
const path = __importStar(require("path"));
|
|
38
45
|
const db_proxy_1 = require("../sdk/db_proxy");
|
|
39
46
|
class PluginManager {
|
|
@@ -42,12 +49,14 @@ class PluginManager {
|
|
|
42
49
|
store;
|
|
43
50
|
apiProxy;
|
|
44
51
|
dbProxy;
|
|
52
|
+
game;
|
|
45
53
|
constructor(config, web, store, apiProxy) {
|
|
46
54
|
this.config = config;
|
|
47
55
|
this.web = web;
|
|
48
56
|
this.store = store;
|
|
49
57
|
this.apiProxy = apiProxy;
|
|
50
58
|
this.dbProxy = new db_proxy_1.DbProxy();
|
|
59
|
+
this.game = new sdk_1.GameSDK(() => this.web.getPage(), this.config);
|
|
51
60
|
}
|
|
52
61
|
async execute(actionName, args, saveAs) {
|
|
53
62
|
const lastDotIndex = actionName.lastIndexOf(".");
|
|
@@ -56,6 +65,16 @@ class PluginManager {
|
|
|
56
65
|
}
|
|
57
66
|
const pluginFile = actionName.substring(0, lastDotIndex);
|
|
58
67
|
const functionName = actionName.substring(lastDotIndex + 1);
|
|
68
|
+
if (pluginFile === 'flowstride:game') {
|
|
69
|
+
const actions = ['tap', 'drag', 'swipe', 'key', 'checkpoint', 'snapshot', 'expectVisual', 'state', 'expectState', 'snapshotState', 'expectPreserved', 'fixture', 'select'];
|
|
70
|
+
if (!actions.includes(functionName))
|
|
71
|
+
throw new Error(`Unknown native game action: ${functionName}`);
|
|
72
|
+
const operation = this.game[functionName](args);
|
|
73
|
+
const result = this.config.ci ? await (0, deadline_1.withDeadline)(operation, this.config.timeout) : await operation;
|
|
74
|
+
if (saveAs)
|
|
75
|
+
this.store.set(saveAs, result);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
59
78
|
let fullPath;
|
|
60
79
|
if (pluginFile.includes("/") || pluginFile.includes("\\")) {
|
|
61
80
|
fullPath = path.resolve(process.cwd(), `${pluginFile}.ts`);
|
|
@@ -64,6 +83,7 @@ class PluginManager {
|
|
|
64
83
|
const pluginsPath = this.config.pluginsDir || path.join(process.cwd(), "plugins");
|
|
65
84
|
fullPath = path.resolve(pluginsPath, `${pluginFile}.ts`);
|
|
66
85
|
}
|
|
86
|
+
fullPath = (0, projectPaths_1.projectPath)(process.cwd(), fullPath);
|
|
67
87
|
try {
|
|
68
88
|
let module;
|
|
69
89
|
if (fullPath.endsWith(".ts")) {
|
|
@@ -84,6 +104,19 @@ class PluginManager {
|
|
|
84
104
|
throw new Error(`Plugin function "${functionName}" not found in ${fullPath}`);
|
|
85
105
|
}
|
|
86
106
|
const sdk = {
|
|
107
|
+
game: this.game,
|
|
108
|
+
browser: { page: () => this.web.getPage() },
|
|
109
|
+
run: { device: this.config.device || 'desktop', artifactsDir: this.config.artifactsDir || path.resolve('flowstride-artifacts'),
|
|
110
|
+
attach: (name, data) => {
|
|
111
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(name) || name === '.' || name === '..')
|
|
112
|
+
throw new Error('Attachment name must be a simple filename.');
|
|
113
|
+
const directory = this.config.artifactsDir || path.resolve('flowstride-artifacts');
|
|
114
|
+
node_fs_1.default.mkdirSync(directory, { recursive: true });
|
|
115
|
+
const prefix = (0, projectPaths_1.artifactPrefix)(this.config.testFile);
|
|
116
|
+
const file = path.join(directory, prefix + name);
|
|
117
|
+
node_fs_1.default.writeFileSync(file, data);
|
|
118
|
+
return file;
|
|
119
|
+
} },
|
|
87
120
|
api: {
|
|
88
121
|
get: (url, headers) => this.apiProxy.get(url, headers),
|
|
89
122
|
post: (url, body, headers) => this.apiProxy.post(url, body, headers),
|
|
@@ -150,7 +183,8 @@ class PluginManager {
|
|
|
150
183
|
.substring(2, 2 + length),
|
|
151
184
|
},
|
|
152
185
|
};
|
|
153
|
-
const
|
|
186
|
+
const operation = action(sdk, args);
|
|
187
|
+
const result = this.config.ci ? await (0, deadline_1.withDeadline)(operation, this.config.timeout) : await operation;
|
|
154
188
|
if (saveAs) {
|
|
155
189
|
this.store.set(saveAs, result);
|
|
156
190
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isLocalDashboardRequest = isLocalDashboardRequest;
|
|
4
|
+
/** Reject remote hosts and browser origins before exposing local debugger data. */
|
|
5
|
+
function isLocalDashboardRequest(host, origin) {
|
|
6
|
+
if (!host)
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
const target = new URL(`http://${host}`);
|
|
10
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname) || !target.port || target.username || target.password || target.pathname !== '/' || target.search || target.hash)
|
|
11
|
+
return false;
|
|
12
|
+
if (origin !== undefined) {
|
|
13
|
+
const source = new URL(origin);
|
|
14
|
+
if (source.origin !== target.origin || source.pathname !== '/' || source.search || source.hash || source.username || source.password)
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function redactText(input: string, secrets?: string[]): string;
|
|
2
|
+
export declare function redactPayload(value: any, secrets?: string[], depth?: number): any;
|
|
3
|
+
/** Raw network commands belong only to the local interactive debugger. */
|
|
4
|
+
export declare function redactWorkerPayload(type: string, payload: any, secrets: string[], localDebug?: boolean): any;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactText = redactText;
|
|
4
|
+
exports.redactPayload = redactPayload;
|
|
5
|
+
exports.redactWorkerPayload = redactWorkerPayload;
|
|
6
|
+
const sensitive = /password|passcode|secret|token|authorization|cookie|api.?key|master.?key|otp|email|curlString|strippedDom/i;
|
|
7
|
+
function redactText(input, secrets = []) {
|
|
8
|
+
let value = String(input);
|
|
9
|
+
for (const secret of secrets.filter(s => typeof s === 'string' && s.length >= 4).sort((a, b) => b.length - a.length))
|
|
10
|
+
value = value.split(secret).join('[REDACTED]');
|
|
11
|
+
value = value.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [REDACTED]')
|
|
12
|
+
.replace(/\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g, '[REDACTED]')
|
|
13
|
+
.replace(/(["']?(?:password|passcode|secret|token|otp|authorization|cookie|api[_-]?key|master[_-]?key)["']?\s*[:=]\s*)(["']?)[^\s,;"'<>}]+\2/gi, '$1[REDACTED]')
|
|
14
|
+
.replace(/([?&](?:ticket|token|signature|x-amz-[^=&#]+)=)[^&#\s]+/gi, '$1[REDACTED]');
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function redactPayload(value, secrets = [], depth = 0) {
|
|
18
|
+
if (depth > 12)
|
|
19
|
+
return '[omitted]';
|
|
20
|
+
if (typeof value === 'string') {
|
|
21
|
+
if (/^data:image\//.test(value))
|
|
22
|
+
return value;
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.stringify(redactPayload(JSON.parse(value), secrets, depth + 1));
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
}
|
|
30
|
+
return redactText(value, secrets);
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map(item => redactPayload(item, secrets, depth + 1));
|
|
34
|
+
if (!value || typeof value !== 'object')
|
|
35
|
+
return value;
|
|
36
|
+
if (Buffer.isBuffer(value))
|
|
37
|
+
return '[binary]';
|
|
38
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitive.test(key) ? '[REDACTED]' : redactPayload(item, secrets, depth + 1)]));
|
|
39
|
+
}
|
|
40
|
+
/** Raw network commands belong only to the local interactive debugger. */
|
|
41
|
+
function redactWorkerPayload(type, payload, secrets, localDebug = false) {
|
|
42
|
+
const result = redactPayload(payload, secrets);
|
|
43
|
+
if (!localDebug || !payload || !result)
|
|
44
|
+
return result;
|
|
45
|
+
if (type === 'LOG_EVENT' && ['REST', 'GRAPHQL'].includes(payload.type) && typeof payload.details?.curlString === 'string') {
|
|
46
|
+
result.details.rawCurlString = payload.details.curlString;
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,38 @@
|
|
|
1
1
|
export interface FlowstrideConfig {
|
|
2
|
+
ci?: boolean;
|
|
3
|
+
flowRoot?: string;
|
|
4
|
+
projectRoot?: string;
|
|
5
|
+
cloudSyncResult?: {
|
|
6
|
+
reporting: string;
|
|
7
|
+
video: string;
|
|
8
|
+
error?: unknown;
|
|
9
|
+
};
|
|
10
|
+
device?: 'desktop' | 'phone' | 'landscape';
|
|
11
|
+
viewport?: {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
};
|
|
15
|
+
testDir?: string;
|
|
16
|
+
artifactsDir?: string;
|
|
17
|
+
recordVideo?: boolean;
|
|
18
|
+
browserErrors?: 'fail' | 'record';
|
|
19
|
+
webServer?: {
|
|
20
|
+
command: string;
|
|
21
|
+
url: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
};
|
|
25
|
+
testFile?: string;
|
|
26
|
+
onRunEvent?: (event: any) => void;
|
|
27
|
+
bail?: boolean;
|
|
28
|
+
isCloudSyncActive?: boolean;
|
|
29
|
+
machineToken?: string | null;
|
|
30
|
+
workspaceId?: string | null;
|
|
31
|
+
manifest?: {
|
|
32
|
+
plan?: string;
|
|
33
|
+
maxWorkers?: number | 'unlimited';
|
|
34
|
+
[key: string]: any;
|
|
35
|
+
} | null;
|
|
2
36
|
baseUrl?: string;
|
|
3
37
|
timeout: number;
|
|
4
38
|
headless: boolean;
|
|
@@ -46,6 +80,15 @@ export interface SessionDefinition {
|
|
|
46
80
|
};
|
|
47
81
|
}
|
|
48
82
|
export interface FlowstrideSDK {
|
|
83
|
+
game: import('../game/sdk').GameSDK;
|
|
84
|
+
browser: {
|
|
85
|
+
page: () => import('playwright').Page;
|
|
86
|
+
};
|
|
87
|
+
run: {
|
|
88
|
+
device: string;
|
|
89
|
+
artifactsDir: string;
|
|
90
|
+
attach: (name: string, data: string | Buffer) => string;
|
|
91
|
+
};
|
|
49
92
|
api: {
|
|
50
93
|
get: (url: string, headers?: Record<string, string>) => Promise<any>;
|
|
51
94
|
post: (url: string, body: any, headers?: Record<string, string>) => Promise<any>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cloudApiBase = cloudApiBase;
|
|
4
|
+
exports.cloudFailure = cloudFailure;
|
|
5
|
+
const redaction_1 = require("../security/redaction");
|
|
6
|
+
function cloudApiBase(env = process.env) {
|
|
7
|
+
const value = env.FLOWSTRIDE_API_URL || env.NEXTJS_WEBHOOK_URL || 'https://cloud.flowstride.io';
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
10
|
+
throw new Error('Cloud URL must be an HTTP(S) URL without credentials or query parameters.');
|
|
11
|
+
const pathname = url.pathname.replace(/\/api\/cloud\/execution-complete\/?$/, '').replace(/\/+$/, '');
|
|
12
|
+
return url.origin + pathname;
|
|
13
|
+
}
|
|
14
|
+
function cloudFailure(error, secrets = []) {
|
|
15
|
+
const status = Number(error?.response?.status) || undefined;
|
|
16
|
+
const code = typeof error?.response?.data?.code === 'string' ? error.response.data.code.slice(0, 80) : typeof error?.code === 'string' ? error.code.slice(0, 80) : undefined;
|
|
17
|
+
const authenticationMessages = {
|
|
18
|
+
TOKEN_EXPIRED: 'This developer token has expired. Generate a replacement; logging in with the same key does not renew it.',
|
|
19
|
+
TOKEN_REVOKED: 'This developer token has been revoked. Use a replacement token.',
|
|
20
|
+
TOKEN_INVALID: 'Cloud did not recognize a valid developer token. Check the credential source and Cloud URL.',
|
|
21
|
+
TOKEN_REQUIRED: 'Cloud requires a Bearer developer token.',
|
|
22
|
+
TOKEN_SCOPE_DENIED: 'This token requires execution or admin scope for CLI runs.',
|
|
23
|
+
};
|
|
24
|
+
const message = (status === 401 || status === 403) && code && authenticationMessages[code] ? authenticationMessages[code]
|
|
25
|
+
: status === 401 ? 'Cloud rejected authentication. Check the selected token and Cloud URL; this response does not confirm expiration.'
|
|
26
|
+
: status === 403 ? 'Workspace permission or feature policy rejected this request.'
|
|
27
|
+
: status === 413 ? 'The Cloud/proxy upload limit was exceeded; evidence remains local.'
|
|
28
|
+
: status === 429 ? 'The Cloud request rate limit was reached.'
|
|
29
|
+
: status && status >= 500 ? 'Cloud service failed; evidence remains local.'
|
|
30
|
+
: status === 400 ? 'Cloud rejected the report schema; verify matching CLI and Cloud releases.'
|
|
31
|
+
: 'Cloud could not be reached; verify the configured URL and network connection.';
|
|
32
|
+
return { status, code: code ? (0, redaction_1.redactText)(code, secrets) : undefined, message };
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function projectPath(root: string, target: string): string;
|
|
2
|
+
export declare function discoverFlows(target: string, projectRoot?: string): string[];
|
|
3
|
+
export declare function flowIdentity(file: string, root?: string): string;
|
|
4
|
+
export declare function videoFileName(identity: string): string;
|
|
5
|
+
export declare function selectedFeatures<T extends {
|
|
6
|
+
scenarios: {
|
|
7
|
+
isOnly?: boolean;
|
|
8
|
+
steps: unknown[];
|
|
9
|
+
}[];
|
|
10
|
+
}>(features: T[]): T[];
|
|
11
|
+
export declare function artifactPrefix(identity?: string): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
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.projectPath = projectPath;
|
|
7
|
+
exports.discoverFlows = discoverFlows;
|
|
8
|
+
exports.flowIdentity = flowIdentity;
|
|
9
|
+
exports.videoFileName = videoFileName;
|
|
10
|
+
exports.selectedFeatures = selectedFeatures;
|
|
11
|
+
exports.artifactPrefix = artifactPrefix;
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
function projectPath(root, target) {
|
|
16
|
+
root = node_path_1.default.resolve(root);
|
|
17
|
+
target = node_path_1.default.resolve(root, target);
|
|
18
|
+
const inside = (base, value) => {
|
|
19
|
+
const relative = node_path_1.default.relative(base, value);
|
|
20
|
+
return relative !== '..' && !relative.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(relative);
|
|
21
|
+
};
|
|
22
|
+
if (!inside(root, target))
|
|
23
|
+
throw new Error(`Path must remain inside the project: ${target}`);
|
|
24
|
+
const realRoot = node_fs_1.default.realpathSync(root);
|
|
25
|
+
let parent = target;
|
|
26
|
+
const suffix = [];
|
|
27
|
+
while (true) {
|
|
28
|
+
try {
|
|
29
|
+
node_fs_1.default.lstatSync(parent);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== 'ENOENT')
|
|
34
|
+
throw error;
|
|
35
|
+
suffix.unshift(node_path_1.default.basename(parent));
|
|
36
|
+
const next = node_path_1.default.dirname(parent);
|
|
37
|
+
if (next === parent)
|
|
38
|
+
throw error;
|
|
39
|
+
parent = next;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const real = node_path_1.default.join(node_fs_1.default.realpathSync(parent), ...suffix);
|
|
43
|
+
if (!inside(realRoot, real))
|
|
44
|
+
throw new Error(`Symlink escapes the project: ${target}`);
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
function discoverFlows(target, projectRoot = process.cwd()) {
|
|
48
|
+
const files = [];
|
|
49
|
+
const visited = new Set();
|
|
50
|
+
const visit = (item) => {
|
|
51
|
+
item = projectPath(projectRoot, item);
|
|
52
|
+
const real = node_fs_1.default.realpathSync(item);
|
|
53
|
+
if (visited.has(real))
|
|
54
|
+
return;
|
|
55
|
+
visited.add(real);
|
|
56
|
+
const stat = node_fs_1.default.statSync(item);
|
|
57
|
+
if (stat.isFile()) {
|
|
58
|
+
if (item.endsWith('.flow'))
|
|
59
|
+
files.push(item);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!stat.isDirectory())
|
|
63
|
+
return;
|
|
64
|
+
for (const entry of node_fs_1.default.readdirSync(item, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
65
|
+
if (['node_modules', '.git', '.flowstride', 'flowstride-artifacts'].includes(entry.name))
|
|
66
|
+
continue;
|
|
67
|
+
visit(node_path_1.default.join(item, entry.name));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
visit(target);
|
|
71
|
+
return files.sort((a, b) => Number(!a.endsWith('.first.flow')) - Number(!b.endsWith('.first.flow')) || (a < b ? -1 : a > b ? 1 : 0));
|
|
72
|
+
}
|
|
73
|
+
function flowIdentity(file, root) { return (root ? node_path_1.default.relative(root, file) : node_path_1.default.basename(file)).split(node_path_1.default.sep).join('/'); }
|
|
74
|
+
function videoFileName(identity) {
|
|
75
|
+
if (!identity.includes('/') && identity.length < 180 && !identity.includes('%'))
|
|
76
|
+
return identity.replace(/\.flow$/, '') + '.webm';
|
|
77
|
+
const base = node_path_1.default.posix.basename(identity, '.flow').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 60);
|
|
78
|
+
return `${base}-${(0, node_crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 16)}.webm`;
|
|
79
|
+
}
|
|
80
|
+
function selectedFeatures(features) {
|
|
81
|
+
if (!features.some(f => f.scenarios.some(s => s.isOnly)))
|
|
82
|
+
return features;
|
|
83
|
+
return features.map(f => ({ ...f, scenarios: f.scenarios.filter(s => s.isOnly) }));
|
|
84
|
+
}
|
|
85
|
+
function artifactPrefix(identity) {
|
|
86
|
+
if (!identity)
|
|
87
|
+
return '';
|
|
88
|
+
const safe = identity.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 100);
|
|
89
|
+
return safe + (identity.includes('/') || identity.length > 100 ? '-' + (0, node_crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 12) : '') + '-';
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Feature: Native canvas game testing
|
|
2
|
+
let game = "flowstride:game"
|
|
3
|
+
Scenario: A tap changes game state and rendering
|
|
4
|
+
Given "The game is ready"
|
|
5
|
+
flow.open "http://127.0.0.1:5189"
|
|
6
|
+
game.fixture {"name":"reset"}
|
|
7
|
+
game.snapshot {"name":"before","canvas":"#game"}
|
|
8
|
+
When "The player taps the game"
|
|
9
|
+
game.tap {"canvas":"#game","x":0.5,"y":0.5}
|
|
10
|
+
Then "The score and scene change"
|
|
11
|
+
game.expectState {"path":"score","equals":1}
|
|
12
|
+
game.expectVisual {"name":"before","canvas":"#game","minChange":0.9}
|
|
13
|
+
game.checkpoint {"name":"game-result","canvas":"#game"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { registerGame } from 'flowstride/dist/game/bridge.js';
|
|
3
|
+
const html = `<!doctype html><html><head><title>Flowstride native game fixture</title></head><body>
|
|
4
|
+
<canvas id="game" width="300" height="200" style="touch-action:none"></canvas>
|
|
5
|
+
<script>
|
|
6
|
+
const canvas=document.querySelector('canvas'),context=canvas.getContext('2d');
|
|
7
|
+
const state={score:0};
|
|
8
|
+
function draw(){context.fillStyle=state.score?'blue':'red';context.fillRect(0,0,canvas.width,canvas.height);}
|
|
9
|
+
canvas.onpointerdown=()=>{state.score++;draw();};draw();
|
|
10
|
+
const register=${registerGame.toString()};register({readState:()=>state,testMode:true,fixtures:{reset:()=>{state.score=0;draw();}}});
|
|
11
|
+
</script></body></html>`;
|
|
12
|
+
http.createServer((_request,response)=>{response.setHeader('Content-Type','text/html');response.end(html);}).listen(5189,'127.0.0.1');
|