flowstride 1.0.18 → 1.0.20-rc.2
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 +155 -0
- package/README.md +40 -33
- package/RELEASE_NOTES.md +40 -0
- package/dist/adapters/web/playwright.d.ts +4 -0
- package/dist/adapters/web/playwright.js +85 -38
- package/dist/ci/config.d.ts +3 -0
- package/dist/ci/config.js +76 -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 +190 -0
- package/dist/cli/commands/open.d.ts +1 -0
- package/dist/cli/commands/open.js +12 -0
- package/dist/cli/commands/run.js +15 -34
- package/dist/cli/index.js +51 -5
- package/dist/cli/manual_server/workspace_server.d.ts +1 -0
- package/dist/cli/manual_server/workspace_server.js +751 -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 +255 -0
- package/dist/game/three-bridge.d.ts +18 -0
- package/dist/game/three-bridge.js +59 -0
- package/dist/parser/lexer.js +4 -0
- package/dist/parser/parser.js +19 -3
- package/dist/runner/flow_orchestrator.d.ts +5 -1
- package/dist/runner/flow_orchestrator.js +31 -7
- package/dist/runner/flow_worker.js +57 -13
- package/dist/runner/plugin_manager.d.ts +1 -0
- package/dist/runner/plugin_manager.js +33 -1
- package/dist/types/index.d.ts +36 -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 +14 -5
- package/src/ui/dist/assets/index-DIC22ZK_.js +638 -0
- package/src/ui/dist/assets/workspace_engine_bg-BVlpAZ1H.wasm +0 -0
- package/src/ui/dist/index.html +1 -1
- package/src/ui/dist/assets/index-BCdexgt5.js +0 -270
|
@@ -353,7 +353,6 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
353
353
|
this.isAborted = true;
|
|
354
354
|
}
|
|
355
355
|
async runBatch(filesToProcess) {
|
|
356
|
-
// this.config.headless = true;
|
|
357
356
|
const isCliHeadless = process.argv.includes("--headless");
|
|
358
357
|
if (isCliHeadless) {
|
|
359
358
|
this.config.headless = true;
|
|
@@ -365,6 +364,10 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
365
364
|
this.isAborted = false;
|
|
366
365
|
this.isDebugHalted = false;
|
|
367
366
|
const sigintHandler = async () => {
|
|
367
|
+
if (this.config.ci) {
|
|
368
|
+
this.abort();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
368
371
|
if (!this.isSilent) {
|
|
369
372
|
console.log(chalk_1.default.yellow("\n[Flowstride]: Process interrupted. Gracefully shutting down..."));
|
|
370
373
|
}
|
|
@@ -374,7 +377,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
374
377
|
await this.web.shutdown();
|
|
375
378
|
}
|
|
376
379
|
catch (e) { }
|
|
377
|
-
|
|
380
|
+
if (!this.config.ci)
|
|
381
|
+
process.exit(0);
|
|
378
382
|
};
|
|
379
383
|
process.once("SIGINT", sigintHandler);
|
|
380
384
|
try {
|
|
@@ -398,6 +402,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
398
402
|
try {
|
|
399
403
|
const fileName = path.basename(filePath);
|
|
400
404
|
this.currentFileName = fileName;
|
|
405
|
+
this.config.testFile = fileName;
|
|
401
406
|
this.localVariables = {};
|
|
402
407
|
this.localBrowserSessions = {};
|
|
403
408
|
this.api.resetState();
|
|
@@ -472,20 +477,24 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
472
477
|
.getPage()
|
|
473
478
|
.waitForLoadState("networkidle", { timeout: 4000 })
|
|
474
479
|
.catch(() => { });
|
|
475
|
-
|
|
480
|
+
if (!this.config.ci)
|
|
481
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
476
482
|
}
|
|
477
483
|
}
|
|
478
484
|
if (this.isAborted)
|
|
479
485
|
break;
|
|
480
486
|
if (!this.isSilent)
|
|
481
487
|
console.log(chalk_1.default.gray("\n[Execution]: Finalizing browser actions..."));
|
|
482
|
-
|
|
488
|
+
if (!this.config.ci)
|
|
489
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
483
490
|
try {
|
|
484
491
|
const baseName = this.currentFileName.replace(".flow", "");
|
|
485
492
|
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
486
493
|
if (!fs.existsSync(mockCloudDir))
|
|
487
494
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
488
495
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
496
|
+
for (const message of this.web.pageErrors || [])
|
|
497
|
+
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
489
498
|
await this.web.shutdown(targetVideoPath);
|
|
490
499
|
if (!this.isSilent)
|
|
491
500
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -507,6 +516,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
507
516
|
if (!fs.existsSync(mockCloudDir))
|
|
508
517
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
509
518
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
519
|
+
for (const message of this.web.pageErrors || [])
|
|
520
|
+
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
510
521
|
await this.web.shutdown(targetVideoPath);
|
|
511
522
|
if (!this.isSilent)
|
|
512
523
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -519,7 +530,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
519
530
|
});
|
|
520
531
|
}
|
|
521
532
|
}
|
|
522
|
-
if (!this.isSilent && !this.isAborted) {
|
|
533
|
+
if (!this.config.ci && !this.isSilent && !this.isAborted) {
|
|
523
534
|
if (this.isDebugHalted) {
|
|
524
535
|
console.log(chalk_1.default.cyan("\n[Execution]: Test suite halted by debug flag."));
|
|
525
536
|
}
|
|
@@ -529,6 +540,16 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
529
540
|
}
|
|
530
541
|
}
|
|
531
542
|
finally {
|
|
543
|
+
if (this.config.ci && this.isAborted) {
|
|
544
|
+
try {
|
|
545
|
+
const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
|
|
546
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
547
|
+
await this.web.shutdown(path.join(directory, this.currentFileName.replace(/\.flow$/, '') + '.webm'));
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
this.broadcast('WORKER_ERROR', { message: error.message });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
532
553
|
process.removeListener("SIGINT", sigintHandler);
|
|
533
554
|
}
|
|
534
555
|
}
|
|
@@ -722,14 +743,35 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
722
743
|
}
|
|
723
744
|
switch (action) {
|
|
724
745
|
case "extract":
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
746
|
+
if (["resBody", "resHeader", "cookie"].includes(payload.target)) {
|
|
747
|
+
this.processExtractions([
|
|
748
|
+
{
|
|
749
|
+
target: payload.target,
|
|
750
|
+
jsonPath: payload.jsonPath,
|
|
751
|
+
variableName: payload.variableName,
|
|
752
|
+
isGlobal: payload.isGlobal,
|
|
753
|
+
},
|
|
754
|
+
]);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
let extractedString = "";
|
|
758
|
+
if (payload.target === "text") {
|
|
759
|
+
extractedString = await this.web.extractText(payload.selector, payload.elementType);
|
|
760
|
+
}
|
|
761
|
+
else if (payload.target === "value") {
|
|
762
|
+
extractedString = await this.web.extractValue(payload.selector, payload.elementType);
|
|
763
|
+
}
|
|
764
|
+
else if (payload.target === "attribute") {
|
|
765
|
+
const val = await this.web.getAttribute(payload.selector, payload.attribute, payload.elementType);
|
|
766
|
+
extractedString = val || "";
|
|
767
|
+
}
|
|
768
|
+
if (payload.isGlobal) {
|
|
769
|
+
FlowWorker.globalVariables[payload.variableName] = extractedString;
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
this.localVariables[payload.variableName] = extractedString;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
733
775
|
break;
|
|
734
776
|
case "getOtp":
|
|
735
777
|
if (!this.isSilent) {
|
|
@@ -1176,6 +1218,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1176
1218
|
});
|
|
1177
1219
|
}
|
|
1178
1220
|
async startStreaming() {
|
|
1221
|
+
if (this.config.ci)
|
|
1222
|
+
return;
|
|
1179
1223
|
const page = this.web.getPage();
|
|
1180
1224
|
if (this.currentCdpSession) {
|
|
1181
1225
|
await this.currentCdpSession.detach().catch(() => { });
|
|
@@ -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,14 @@ 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 deadline_1 = require("../ci/deadline");
|
|
41
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
42
|
+
const sdk_1 = require("../game/sdk");
|
|
37
43
|
const path = __importStar(require("path"));
|
|
38
44
|
const db_proxy_1 = require("../sdk/db_proxy");
|
|
39
45
|
class PluginManager {
|
|
@@ -42,12 +48,14 @@ class PluginManager {
|
|
|
42
48
|
store;
|
|
43
49
|
apiProxy;
|
|
44
50
|
dbProxy;
|
|
51
|
+
game;
|
|
45
52
|
constructor(config, web, store, apiProxy) {
|
|
46
53
|
this.config = config;
|
|
47
54
|
this.web = web;
|
|
48
55
|
this.store = store;
|
|
49
56
|
this.apiProxy = apiProxy;
|
|
50
57
|
this.dbProxy = new db_proxy_1.DbProxy();
|
|
58
|
+
this.game = new sdk_1.GameSDK(() => this.web.getPage(), this.config);
|
|
51
59
|
}
|
|
52
60
|
async execute(actionName, args, saveAs) {
|
|
53
61
|
const lastDotIndex = actionName.lastIndexOf(".");
|
|
@@ -56,6 +64,16 @@ class PluginManager {
|
|
|
56
64
|
}
|
|
57
65
|
const pluginFile = actionName.substring(0, lastDotIndex);
|
|
58
66
|
const functionName = actionName.substring(lastDotIndex + 1);
|
|
67
|
+
if (pluginFile === 'flowstride:game') {
|
|
68
|
+
const actions = ['tap', 'drag', 'swipe', 'key', 'checkpoint', 'snapshot', 'expectVisual', 'state', 'expectState', 'snapshotState', 'expectPreserved', 'fixture', 'select'];
|
|
69
|
+
if (!actions.includes(functionName))
|
|
70
|
+
throw new Error(`Unknown native game action: ${functionName}`);
|
|
71
|
+
const operation = this.game[functionName](args);
|
|
72
|
+
const result = this.config.ci ? await (0, deadline_1.withDeadline)(operation, this.config.timeout) : await operation;
|
|
73
|
+
if (saveAs)
|
|
74
|
+
this.store.set(saveAs, result);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
59
77
|
let fullPath;
|
|
60
78
|
if (pluginFile.includes("/") || pluginFile.includes("\\")) {
|
|
61
79
|
fullPath = path.resolve(process.cwd(), `${pluginFile}.ts`);
|
|
@@ -84,6 +102,19 @@ class PluginManager {
|
|
|
84
102
|
throw new Error(`Plugin function "${functionName}" not found in ${fullPath}`);
|
|
85
103
|
}
|
|
86
104
|
const sdk = {
|
|
105
|
+
game: this.game,
|
|
106
|
+
browser: { page: () => this.web.getPage() },
|
|
107
|
+
run: { device: this.config.device || 'desktop', artifactsDir: this.config.artifactsDir || path.resolve('flowstride-artifacts'),
|
|
108
|
+
attach: (name, data) => {
|
|
109
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(name) || name === '.' || name === '..')
|
|
110
|
+
throw new Error('Attachment name must be a simple filename.');
|
|
111
|
+
const directory = this.config.artifactsDir || path.resolve('flowstride-artifacts');
|
|
112
|
+
node_fs_1.default.mkdirSync(directory, { recursive: true });
|
|
113
|
+
const prefix = this.config.testFile ? this.config.testFile.replace(/[^a-zA-Z0-9_.-]/g, '_') + '-' : '';
|
|
114
|
+
const file = path.join(directory, prefix + name);
|
|
115
|
+
node_fs_1.default.writeFileSync(file, data);
|
|
116
|
+
return file;
|
|
117
|
+
} },
|
|
87
118
|
api: {
|
|
88
119
|
get: (url, headers) => this.apiProxy.get(url, headers),
|
|
89
120
|
post: (url, body, headers) => this.apiProxy.post(url, body, headers),
|
|
@@ -150,7 +181,8 @@ class PluginManager {
|
|
|
150
181
|
.substring(2, 2 + length),
|
|
151
182
|
},
|
|
152
183
|
};
|
|
153
|
-
const
|
|
184
|
+
const operation = action(sdk, args);
|
|
185
|
+
const result = this.config.ci ? await (0, deadline_1.withDeadline)(operation, this.config.timeout) : await operation;
|
|
154
186
|
if (saveAs) {
|
|
155
187
|
this.store.set(saveAs, result);
|
|
156
188
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
export interface FlowstrideConfig {
|
|
2
|
+
ci?: boolean;
|
|
3
|
+
device?: 'desktop' | 'phone' | 'landscape';
|
|
4
|
+
viewport?: {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
testDir?: string;
|
|
9
|
+
artifactsDir?: string;
|
|
10
|
+
recordVideo?: boolean;
|
|
11
|
+
browserErrors?: 'fail' | 'record';
|
|
12
|
+
webServer?: {
|
|
13
|
+
command: string;
|
|
14
|
+
url: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
};
|
|
18
|
+
testFile?: string;
|
|
19
|
+
onRunEvent?: (event: any) => void;
|
|
20
|
+
bail?: boolean;
|
|
21
|
+
isCloudSyncActive?: boolean;
|
|
22
|
+
machineToken?: string | null;
|
|
23
|
+
workspaceId?: string | null;
|
|
24
|
+
manifest?: {
|
|
25
|
+
plan?: string;
|
|
26
|
+
maxWorkers?: number | 'unlimited';
|
|
27
|
+
[key: string]: any;
|
|
28
|
+
} | null;
|
|
2
29
|
baseUrl?: string;
|
|
3
30
|
timeout: number;
|
|
4
31
|
headless: boolean;
|
|
@@ -46,6 +73,15 @@ export interface SessionDefinition {
|
|
|
46
73
|
};
|
|
47
74
|
}
|
|
48
75
|
export interface FlowstrideSDK {
|
|
76
|
+
game: import('../game/sdk').GameSDK;
|
|
77
|
+
browser: {
|
|
78
|
+
page: () => import('playwright').Page;
|
|
79
|
+
};
|
|
80
|
+
run: {
|
|
81
|
+
device: string;
|
|
82
|
+
artifactsDir: string;
|
|
83
|
+
attach: (name: string, data: string | Buffer) => string;
|
|
84
|
+
};
|
|
49
85
|
api: {
|
|
50
86
|
get: (url: string, headers?: Record<string, string>) => Promise<any>;
|
|
51
87
|
post: (url: string, body: any, headers?: Record<string, string>) => Promise<any>;
|
|
@@ -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');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowstride",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.20-rc.2",
|
|
4
4
|
"description": "An opensource flowfirst test automation framework",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"packages/flowstride_report"
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"types": "dist/index.d.ts",
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
-
"src/ui/dist"
|
|
12
|
+
"src/ui/dist",
|
|
13
|
+
"NATIVE_CI.md",
|
|
14
|
+
"examples/native-game",
|
|
15
|
+
"RELEASE_NOTES.md"
|
|
13
16
|
],
|
|
14
17
|
"bin": {
|
|
15
18
|
"flowstride": "dist/cli/index.js"
|
|
@@ -18,7 +21,8 @@
|
|
|
18
21
|
"build": "tsc",
|
|
19
22
|
"flowstride": "node ./dist/cli/index.js",
|
|
20
23
|
"test": "echo 'Test script to be configured'",
|
|
21
|
-
"prepublishOnly": "npm run build"
|
|
24
|
+
"prepublishOnly": "npm run build",
|
|
25
|
+
"test:native": "node --test --test-concurrency=1 tests/native/*.test.cjs"
|
|
22
26
|
},
|
|
23
27
|
"keywords": [
|
|
24
28
|
"automation",
|
|
@@ -29,7 +33,7 @@
|
|
|
29
33
|
"author": "Samuel Okolo",
|
|
30
34
|
"license": "MIT",
|
|
31
35
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
36
|
+
"node": ">=22.12.0"
|
|
33
37
|
},
|
|
34
38
|
"dependencies": {
|
|
35
39
|
"@inquirer/prompts": "^7.10.1",
|
|
@@ -39,7 +43,7 @@
|
|
|
39
43
|
"dotenv": "^17.4.2",
|
|
40
44
|
"open": "^11.0.0",
|
|
41
45
|
"ora": "^5.4.1",
|
|
42
|
-
"playwright": "
|
|
46
|
+
"playwright": "1.59.1",
|
|
43
47
|
"playwright-extra": "^4.3.6",
|
|
44
48
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
45
49
|
"tar": "^7.5.16",
|
|
@@ -54,5 +58,10 @@
|
|
|
54
58
|
},
|
|
55
59
|
"optionalDependencies": {
|
|
56
60
|
"@rolldown/binding-darwin-arm64": "^0.15.1"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"tag": "next",
|
|
64
|
+
"access": "public",
|
|
65
|
+
"registry": "https://registry.npmjs.org"
|
|
57
66
|
}
|
|
58
67
|
}
|