flowstride 1.0.19 → 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 +1 -0
- package/dist/adapters/web/playwright.js +18 -14
- 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/run.js +15 -34
- package/dist/cli/index.js +44 -5
- 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/runner/flow_orchestrator.d.ts +5 -1
- package/dist/runner/flow_orchestrator.js +31 -7
- package/dist/runner/flow_worker.js +28 -4
- 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/dist/game/sdk.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
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.GameSDK = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
10
|
+
const promises_1 = require("node:timers/promises");
|
|
11
|
+
class GameSDK {
|
|
12
|
+
page;
|
|
13
|
+
config;
|
|
14
|
+
snapshots = new Map();
|
|
15
|
+
states = new Map();
|
|
16
|
+
constructor(page, config) {
|
|
17
|
+
this.page = page;
|
|
18
|
+
this.config = config;
|
|
19
|
+
}
|
|
20
|
+
timeout() { return this.config.timeout; }
|
|
21
|
+
async bridgeReady(method) {
|
|
22
|
+
await this.page().waitForFunction(method => {
|
|
23
|
+
const bridge = window.__FLOWSTRIDE_GAME__;
|
|
24
|
+
return !!bridge && (!method || typeof bridge[method] === 'function');
|
|
25
|
+
}, method, { timeout: this.timeout() });
|
|
26
|
+
}
|
|
27
|
+
async point(options) {
|
|
28
|
+
const canvas = this.page().locator(options.canvas || 'canvas');
|
|
29
|
+
await canvas.waitFor({ state: 'visible', timeout: this.timeout() });
|
|
30
|
+
const box = await canvas.boundingBox();
|
|
31
|
+
if (!box || box.width <= 0 || box.height <= 0)
|
|
32
|
+
throw new Error('Canvas has no usable bounds.');
|
|
33
|
+
let { x, y } = options;
|
|
34
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
35
|
+
throw new Error('Canvas coordinates must be finite.');
|
|
36
|
+
if ((options.unit || 'relative') === 'relative') {
|
|
37
|
+
x *= box.width;
|
|
38
|
+
y *= box.height;
|
|
39
|
+
}
|
|
40
|
+
else if (options.unit !== 'pixels')
|
|
41
|
+
throw new Error('Coordinate unit must be relative or pixels.');
|
|
42
|
+
if (x < 0 || x >= box.width || y < 0 || y >= box.height)
|
|
43
|
+
throw new Error('Canvas coordinate is outside its bounds.');
|
|
44
|
+
const point = { x: box.x + x, y: box.y + y };
|
|
45
|
+
await canvas.evaluate((element, point) => {
|
|
46
|
+
if (document.elementFromPoint(point.x, point.y) !== element)
|
|
47
|
+
throw new Error('Canvas target is covered or outside the viewport.');
|
|
48
|
+
}, point);
|
|
49
|
+
return point;
|
|
50
|
+
}
|
|
51
|
+
touch(options) {
|
|
52
|
+
const input = options.input || (this.config.device === 'desktop' ? 'mouse' : 'touch');
|
|
53
|
+
if (!['mouse', 'touch'].includes(input))
|
|
54
|
+
throw new Error('input must be mouse or touch.');
|
|
55
|
+
return input === 'touch';
|
|
56
|
+
}
|
|
57
|
+
async tap(options) {
|
|
58
|
+
const point = await this.point(options);
|
|
59
|
+
if (this.touch(options))
|
|
60
|
+
await this.page().touchscreen.tap(point.x, point.y);
|
|
61
|
+
else
|
|
62
|
+
await this.page().mouse.click(point.x, point.y);
|
|
63
|
+
}
|
|
64
|
+
async drag(options) {
|
|
65
|
+
const from = await this.point({ ...options, ...options.from });
|
|
66
|
+
const to = await this.point({ ...options, ...options.to });
|
|
67
|
+
const duration = options.duration ?? 250;
|
|
68
|
+
if (!Number.isFinite(duration) || duration < 0 || duration > this.timeout())
|
|
69
|
+
throw new Error('Invalid gesture duration.');
|
|
70
|
+
if (this.touch(options)) {
|
|
71
|
+
const session = await this.page().context().newCDPSession(this.page());
|
|
72
|
+
try {
|
|
73
|
+
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
|
74
|
+
for (let i = 1; i <= 10; i++) {
|
|
75
|
+
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: from.x + (to.x - from.x) * i / 10, y: from.y + (to.y - from.y) * i / 10 }] });
|
|
76
|
+
await (0, promises_1.setTimeout)(duration / 10);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
try {
|
|
81
|
+
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await session.detach();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
await this.page().mouse.move(from.x, from.y);
|
|
90
|
+
await this.page().mouse.down();
|
|
91
|
+
try {
|
|
92
|
+
for (let i = 1; i <= 10; i++) {
|
|
93
|
+
await this.page().mouse.move(from.x + (to.x - from.x) * i / 10, from.y + (to.y - from.y) * i / 10);
|
|
94
|
+
await (0, promises_1.setTimeout)(duration / 10);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await this.page().mouse.up();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async swipe(options) { return this.drag({ ...options, input: 'touch' }); }
|
|
103
|
+
async key(options) {
|
|
104
|
+
const key = typeof options === 'string' ? options : options.key;
|
|
105
|
+
const duration = typeof options === 'string' ? 0 : options.duration ?? 0;
|
|
106
|
+
if (typeof key !== 'string' || !key || !Number.isFinite(duration) || duration < 0 || duration > this.timeout())
|
|
107
|
+
throw new Error('Invalid key or duration.');
|
|
108
|
+
const keys = key === '+' ? [key] : key.split('+');
|
|
109
|
+
const pressed = [];
|
|
110
|
+
try {
|
|
111
|
+
for (const part of keys) {
|
|
112
|
+
await this.page().keyboard.down(part);
|
|
113
|
+
pressed.push(part);
|
|
114
|
+
}
|
|
115
|
+
if (duration)
|
|
116
|
+
await (0, promises_1.setTimeout)(duration);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
const released = await Promise.allSettled(pressed.reverse().map(part => this.page().keyboard.up(part)));
|
|
120
|
+
if (released.some(result => result.status === 'rejected'))
|
|
121
|
+
throw new Error('Failed to release game keyboard input.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async checkpoint(options) {
|
|
125
|
+
const name = typeof options === 'string' ? options : options.name;
|
|
126
|
+
if (typeof name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(name))
|
|
127
|
+
throw new Error('Checkpoint name must use letters, digits, underscores or hyphens.');
|
|
128
|
+
const directory = this.config.artifactsDir || node_path_1.default.resolve('flowstride-artifacts');
|
|
129
|
+
node_fs_1.default.mkdirSync(directory, { recursive: true });
|
|
130
|
+
const prefix = this.config.testFile ? this.config.testFile.replace(/[^a-zA-Z0-9_.-]/g, '_') + '-' : '';
|
|
131
|
+
const file = node_path_1.default.join(directory, prefix + name + '.png');
|
|
132
|
+
if (typeof options === 'object' && options.canvas)
|
|
133
|
+
await this.page().locator(options.canvas).screenshot({ path: file });
|
|
134
|
+
else
|
|
135
|
+
await this.page().screenshot({ path: file });
|
|
136
|
+
return file;
|
|
137
|
+
}
|
|
138
|
+
async snapshot(options) {
|
|
139
|
+
if (!options?.name || typeof options.name !== 'string')
|
|
140
|
+
throw new Error('snapshot requires a name.');
|
|
141
|
+
const buffer = await this.page().locator(options.canvas || 'canvas').screenshot();
|
|
142
|
+
this.snapshots.set(options.name, buffer);
|
|
143
|
+
}
|
|
144
|
+
async difference(before, after) {
|
|
145
|
+
return this.page().evaluate(async ({ before, after }) => {
|
|
146
|
+
async function pixels(data) {
|
|
147
|
+
const image = new Image();
|
|
148
|
+
image.src = 'data:image/png;base64,' + data;
|
|
149
|
+
await image.decode();
|
|
150
|
+
const canvas = document.createElement('canvas');
|
|
151
|
+
canvas.width = image.width;
|
|
152
|
+
canvas.height = image.height;
|
|
153
|
+
const context = canvas.getContext('2d');
|
|
154
|
+
context.drawImage(image, 0, 0);
|
|
155
|
+
return { data: context.getImageData(0, 0, canvas.width, canvas.height).data, width: canvas.width, height: canvas.height };
|
|
156
|
+
}
|
|
157
|
+
const a = await pixels(before), b = await pixels(after);
|
|
158
|
+
if (a.width !== b.width || a.height !== b.height)
|
|
159
|
+
throw new Error('Visual comparison dimensions differ.');
|
|
160
|
+
let changed = 0;
|
|
161
|
+
for (let i = 0; i < a.data.length; i += 4)
|
|
162
|
+
if (Math.abs(a.data[i] - b.data[i]) + Math.abs(a.data[i + 1] - b.data[i + 1]) + Math.abs(a.data[i + 2] - b.data[i + 2]) + Math.abs(a.data[i + 3] - b.data[i + 3]) > 60)
|
|
163
|
+
changed++;
|
|
164
|
+
return changed / (a.width * a.height);
|
|
165
|
+
}, { before: before.toString('base64'), after: after.toString('base64') });
|
|
166
|
+
}
|
|
167
|
+
async expectVisual(options) {
|
|
168
|
+
let before;
|
|
169
|
+
if (options.baseline)
|
|
170
|
+
before = node_fs_1.default.readFileSync(node_path_1.default.resolve(options.baseline));
|
|
171
|
+
else
|
|
172
|
+
before = this.snapshots.get(options.name);
|
|
173
|
+
if (!before)
|
|
174
|
+
throw new Error('Visual snapshot or baseline is missing.');
|
|
175
|
+
const ratio = await this.difference(before, await this.page().locator(options.canvas || 'canvas').screenshot());
|
|
176
|
+
const min = options.minChange ?? 0, max = options.maxChange ?? (options.minChange === undefined ? 0 : 1);
|
|
177
|
+
if (![min, max].every(Number.isFinite) || min < 0 || max > 1 || min > max)
|
|
178
|
+
throw new Error('Visual change thresholds must satisfy 0 <= min <= max <= 1.');
|
|
179
|
+
(0, strict_1.default)(ratio >= min && ratio <= max, `Canvas changed by ${ratio}; expected between ${min} and ${max}.`);
|
|
180
|
+
return ratio;
|
|
181
|
+
}
|
|
182
|
+
async state(options = {}) {
|
|
183
|
+
await this.bridgeReady('readState');
|
|
184
|
+
return this.page().evaluate(async (field) => {
|
|
185
|
+
const bridge = window.__FLOWSTRIDE_GAME__;
|
|
186
|
+
if (!bridge?.readState)
|
|
187
|
+
throw new Error('Game state bridge is not registered.');
|
|
188
|
+
let value = await bridge.readState();
|
|
189
|
+
for (const part of field?.split('.') || []) {
|
|
190
|
+
if (['__proto__', 'constructor', 'prototype'].includes(part) || value == null || !Object.hasOwn(value, part))
|
|
191
|
+
throw new Error(`Game state field is unavailable: ${field}`);
|
|
192
|
+
value = value[part];
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}, options.path);
|
|
196
|
+
}
|
|
197
|
+
async expectState(options) {
|
|
198
|
+
if (!options || !Object.hasOwn(options, 'equals'))
|
|
199
|
+
throw new Error('expectState requires equals.');
|
|
200
|
+
const deadline = Date.now() + this.timeout();
|
|
201
|
+
let actual;
|
|
202
|
+
let observed = false;
|
|
203
|
+
do {
|
|
204
|
+
try {
|
|
205
|
+
actual = await this.state(options);
|
|
206
|
+
observed = true;
|
|
207
|
+
strict_1.default.deepStrictEqual(actual, options.equals);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
catch { }
|
|
211
|
+
await (0, promises_1.setTimeout)(50);
|
|
212
|
+
} while (Date.now() < deadline);
|
|
213
|
+
if (!observed)
|
|
214
|
+
throw new Error('Game state was not readable before the timeout.');
|
|
215
|
+
strict_1.default.deepStrictEqual(actual, options.equals, `Game state did not reach the expected value: ${options.path || 'root'}`);
|
|
216
|
+
}
|
|
217
|
+
async snapshotState(options) {
|
|
218
|
+
if (!options?.name)
|
|
219
|
+
throw new Error('snapshotState requires name.');
|
|
220
|
+
this.states.set(options.name, { path: options.path, value: await this.state(options) });
|
|
221
|
+
}
|
|
222
|
+
async expectPreserved(options) {
|
|
223
|
+
if (!this.states.has(options.name))
|
|
224
|
+
throw new Error('State snapshot missing.');
|
|
225
|
+
const snapshot = this.states.get(options.name);
|
|
226
|
+
strict_1.default.deepStrictEqual(await this.state({ path: options.path ?? snapshot.path }), snapshot.value, 'Game state changed.');
|
|
227
|
+
}
|
|
228
|
+
async fixture(options) {
|
|
229
|
+
await this.bridgeReady();
|
|
230
|
+
return this.page().evaluate(async ({ name, data }) => {
|
|
231
|
+
const bridge = window.__FLOWSTRIDE_GAME__;
|
|
232
|
+
if (!bridge?.testMode || !Object.hasOwn(bridge.fixtures || {}, name) || typeof bridge.fixtures[name] !== 'function')
|
|
233
|
+
throw new Error('Named fixture is unavailable in this environment.');
|
|
234
|
+
return bridge.fixtures[name](data);
|
|
235
|
+
}, { name: options.name, data: options.data });
|
|
236
|
+
}
|
|
237
|
+
async select(options) {
|
|
238
|
+
if (typeof options?.id !== 'string' || !options.id.trim())
|
|
239
|
+
throw new Error('select requires a nonempty game object ID.');
|
|
240
|
+
await this.bridgeReady('locate');
|
|
241
|
+
const point = await this.page().evaluate(async (id) => {
|
|
242
|
+
const bridge = window.__FLOWSTRIDE_GAME__;
|
|
243
|
+
if (!bridge?.locate)
|
|
244
|
+
throw new Error('Game object bridge is not registered.');
|
|
245
|
+
return bridge.locate(id);
|
|
246
|
+
}, options.id);
|
|
247
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
248
|
+
throw new Error('Invalid object coordinates.');
|
|
249
|
+
if (this.touch(options))
|
|
250
|
+
await this.page().touchscreen.tap(point.x, point.y);
|
|
251
|
+
else
|
|
252
|
+
await this.page().mouse.click(point.x, point.y);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
exports.GameSDK = GameSDK;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Browser-side integration. Import from flowstride/dist/game/three-bridge.
|
|
2
|
+
* The application supplies its own Three.js constructors; no second Three.js
|
|
3
|
+
* runtime, scene reconstruction, renderer or animation loop is created. */
|
|
4
|
+
export interface ThreeGameBridgeOptions {
|
|
5
|
+
scene: any;
|
|
6
|
+
camera: any;
|
|
7
|
+
canvas: HTMLCanvasElement;
|
|
8
|
+
Vector3: new (...args: any[]) => any;
|
|
9
|
+
Raycaster: new (...args: any[]) => any;
|
|
10
|
+
objects?: Record<string, any>;
|
|
11
|
+
readState?: () => unknown;
|
|
12
|
+
testMode?: boolean;
|
|
13
|
+
fixtures?: Record<string, (data: unknown) => unknown>;
|
|
14
|
+
}
|
|
15
|
+
export declare function registerThreeGame(options: ThreeGameBridgeOptions): {
|
|
16
|
+
registerObject(id: string, object: any): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerThreeGame = registerThreeGame;
|
|
4
|
+
function registerThreeGame(options) {
|
|
5
|
+
if (!(options.canvas instanceof HTMLCanvasElement))
|
|
6
|
+
throw new Error('A live game canvas is required.');
|
|
7
|
+
const objects = new Map(Object.entries(options.objects || {}));
|
|
8
|
+
const locate = (id) => {
|
|
9
|
+
let object = objects.get(id);
|
|
10
|
+
if (!object)
|
|
11
|
+
options.scene.traverse((candidate) => { if (candidate.userData?.flowstrideId === id) {
|
|
12
|
+
if (object)
|
|
13
|
+
throw new Error(`Duplicate game object ID: ${id}`);
|
|
14
|
+
object = candidate;
|
|
15
|
+
} });
|
|
16
|
+
if (!object)
|
|
17
|
+
throw new Error(`Game object not found: ${id}`);
|
|
18
|
+
let cursor = object;
|
|
19
|
+
while (cursor) {
|
|
20
|
+
if (cursor.visible === false)
|
|
21
|
+
throw new Error(`Game object is hidden: ${id}`);
|
|
22
|
+
cursor = cursor.parent;
|
|
23
|
+
}
|
|
24
|
+
options.scene.updateMatrixWorld(true);
|
|
25
|
+
options.camera.updateMatrixWorld(true);
|
|
26
|
+
options.camera.updateProjectionMatrix();
|
|
27
|
+
const local = object.userData?.flowstridePickPoint;
|
|
28
|
+
const point = local ? object.localToWorld(new options.Vector3(...local)) : object.getWorldPosition(new options.Vector3());
|
|
29
|
+
point.project(options.camera);
|
|
30
|
+
if (![point.x, point.y, point.z].every(Number.isFinite) || Math.abs(point.x) > 1 || Math.abs(point.y) > 1 || Math.abs(point.z) > 1)
|
|
31
|
+
throw new Error(`Game object is outside the camera: ${id}`);
|
|
32
|
+
const ray = new options.Raycaster();
|
|
33
|
+
ray.layers.mask = options.camera.layers.mask;
|
|
34
|
+
ray.setFromCamera({ x: point.x, y: point.y }, options.camera);
|
|
35
|
+
const hits = ray.intersectObjects(options.scene.children, true);
|
|
36
|
+
const front = hits.find((hit) => { let node = hit.object; while (node) {
|
|
37
|
+
if (node.visible === false)
|
|
38
|
+
return false;
|
|
39
|
+
node = node.parent;
|
|
40
|
+
} return true; });
|
|
41
|
+
cursor = front?.object;
|
|
42
|
+
while (cursor && cursor !== object)
|
|
43
|
+
cursor = cursor.parent;
|
|
44
|
+
if (!cursor)
|
|
45
|
+
throw new Error(`Game object is occluded or not pickable: ${id}`);
|
|
46
|
+
const rect = options.canvas.getBoundingClientRect();
|
|
47
|
+
const x = rect.left + (point.x + 1) * rect.width / 2, y = rect.top + (1 - point.y) * rect.height / 2;
|
|
48
|
+
if (!rect.width || !rect.height || x < 0 || y < 0 || x >= innerWidth || y >= innerHeight || document.elementFromPoint(x, y) !== options.canvas)
|
|
49
|
+
throw new Error(`Game object is covered or outside the viewport: ${id}`);
|
|
50
|
+
return { x, y };
|
|
51
|
+
};
|
|
52
|
+
const bridge = { locate, readState: options.readState, testMode: options.testMode === true,
|
|
53
|
+
fixtures: options.testMode === true ? options.fixtures || {} : {} };
|
|
54
|
+
window.__FLOWSTRIDE_GAME__ = bridge;
|
|
55
|
+
return { registerObject(id, object) { if (!id || objects.has(id))
|
|
56
|
+
throw new Error('Game object ID must be unique and nonempty.'); objects.set(id, object); },
|
|
57
|
+
dispose() { if (window.__FLOWSTRIDE_GAME__ === bridge)
|
|
58
|
+
delete window.__FLOWSTRIDE_GAME__; } };
|
|
59
|
+
}
|
|
@@ -4,6 +4,8 @@ export declare class FlowOrchestrator {
|
|
|
4
4
|
private worker;
|
|
5
5
|
private workerCount;
|
|
6
6
|
private config?;
|
|
7
|
+
static readonly nativeCIProtocol = 1;
|
|
8
|
+
readonly nativeEventProtocol = 1;
|
|
7
9
|
private wss;
|
|
8
10
|
private httpServer;
|
|
9
11
|
private globalTestScenarios;
|
|
@@ -13,6 +15,8 @@ export declare class FlowOrchestrator {
|
|
|
13
15
|
constructor(worker: FlowWorker, workerCount?: number, config?: FlowstrideConfig | undefined);
|
|
14
16
|
private forwardToDashboard;
|
|
15
17
|
private executeTestRun;
|
|
16
|
-
start(targetPath: string): Promise<
|
|
18
|
+
start(targetPath: string): Promise<boolean>;
|
|
19
|
+
abort(): void;
|
|
20
|
+
close(): Promise<void>;
|
|
17
21
|
private handleExportPdfRequest;
|
|
18
22
|
}
|
|
@@ -48,6 +48,8 @@ class FlowOrchestrator {
|
|
|
48
48
|
worker;
|
|
49
49
|
workerCount;
|
|
50
50
|
config;
|
|
51
|
+
static nativeCIProtocol = 1;
|
|
52
|
+
nativeEventProtocol = 1;
|
|
51
53
|
wss = null;
|
|
52
54
|
httpServer = null;
|
|
53
55
|
globalTestScenarios = [];
|
|
@@ -64,6 +66,7 @@ class FlowOrchestrator {
|
|
|
64
66
|
});
|
|
65
67
|
}
|
|
66
68
|
forwardToDashboard(type, payload) {
|
|
69
|
+
this.config?.onRunEvent?.({ type, payload });
|
|
67
70
|
if (this.wss) {
|
|
68
71
|
this.wss.clients.forEach((client) => {
|
|
69
72
|
if (client.readyState === 1)
|
|
@@ -76,10 +79,18 @@ class FlowOrchestrator {
|
|
|
76
79
|
let filesToProcess = [];
|
|
77
80
|
const stats = fs.statSync(targetPath);
|
|
78
81
|
if (stats.isDirectory()) {
|
|
79
|
-
|
|
82
|
+
const allFlowFiles = fs
|
|
80
83
|
.readdirSync(targetPath)
|
|
81
|
-
.filter((f) => f.endsWith(".flow"))
|
|
84
|
+
.filter((f) => f.endsWith(".flow"));
|
|
85
|
+
const priorityQueue = allFlowFiles
|
|
86
|
+
.filter((f) => f.endsWith(".first.flow"))
|
|
87
|
+
.sort()
|
|
82
88
|
.map((f) => path.join(targetPath, f));
|
|
89
|
+
const standardQueue = allFlowFiles
|
|
90
|
+
.filter((f) => !f.endsWith(".first.flow"))
|
|
91
|
+
.sort()
|
|
92
|
+
.map((f) => path.join(targetPath, f));
|
|
93
|
+
filesToProcess = [...priorityQueue, ...standardQueue];
|
|
83
94
|
}
|
|
84
95
|
else {
|
|
85
96
|
filesToProcess = [targetPath];
|
|
@@ -103,10 +114,14 @@ class FlowOrchestrator {
|
|
|
103
114
|
this.worker.on("worker_event", listener);
|
|
104
115
|
await this.worker.runBatch(filesToProcess);
|
|
105
116
|
this.worker.off("worker_event", listener);
|
|
106
|
-
this.
|
|
117
|
+
const passed = ![...this.stepStatusRegistry.values()].some(status => ['failed', 'running', 'not_tested'].includes(status));
|
|
118
|
+
this.forwardToDashboard("RUN_COMPLETE", { status: passed ? 'success' : 'failed' });
|
|
119
|
+
return passed;
|
|
107
120
|
}
|
|
108
121
|
async start(targetPath) {
|
|
109
122
|
try {
|
|
123
|
+
if (this.config?.ci)
|
|
124
|
+
return await this.executeTestRun(targetPath);
|
|
110
125
|
this.httpServer = http.createServer((req, res) => {
|
|
111
126
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
112
127
|
if (req.url && req.url.startsWith("/videos/")) {
|
|
@@ -304,8 +319,8 @@ class FlowOrchestrator {
|
|
|
304
319
|
else {
|
|
305
320
|
console.log(chalk_1.default.gray("[Dashboard]: Headless mode active. Bypassing UI auto-launch..."));
|
|
306
321
|
}
|
|
307
|
-
await this.executeTestRun(targetPath);
|
|
308
|
-
resolve(
|
|
322
|
+
const result = await this.executeTestRun(targetPath);
|
|
323
|
+
resolve(result);
|
|
309
324
|
}
|
|
310
325
|
catch (err) {
|
|
311
326
|
reject(err);
|
|
@@ -317,11 +332,20 @@ class FlowOrchestrator {
|
|
|
317
332
|
throw e;
|
|
318
333
|
}
|
|
319
334
|
}
|
|
335
|
+
abort() {
|
|
336
|
+
this.worker.abort();
|
|
337
|
+
}
|
|
338
|
+
async close() {
|
|
339
|
+
if (this.disconnectTimer)
|
|
340
|
+
clearTimeout(this.disconnectTimer);
|
|
341
|
+
this.wss?.clients.forEach(client => client.terminate());
|
|
342
|
+
this.wss?.close();
|
|
343
|
+
this.httpServer?.close();
|
|
344
|
+
}
|
|
320
345
|
async handleExportPdfRequest(testScenariosFromUI) {
|
|
321
346
|
try {
|
|
322
|
-
const spinner = (0, ora_1.default)(chalk_1.default.blue("Compiling high
|
|
347
|
+
const spinner = (0, ora_1.default)(chalk_1.default.blue("Compiling high fidelity PDF report...")).start();
|
|
323
348
|
const pdfPath = await this.pdfGenerator.generateTestcasePdf(testScenariosFromUI);
|
|
324
|
-
// Convert the saved PDF to base64 to trigger the native browser download UI
|
|
325
349
|
const pdfBuffer = fs.readFileSync(pdfPath);
|
|
326
350
|
const base64Data = pdfBuffer.toString("base64");
|
|
327
351
|
this.forwardToDashboard("EXPORT_PDF_RES", { base64: base64Data });
|
|
@@ -364,6 +364,10 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
364
364
|
this.isAborted = false;
|
|
365
365
|
this.isDebugHalted = false;
|
|
366
366
|
const sigintHandler = async () => {
|
|
367
|
+
if (this.config.ci) {
|
|
368
|
+
this.abort();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
367
371
|
if (!this.isSilent) {
|
|
368
372
|
console.log(chalk_1.default.yellow("\n[Flowstride]: Process interrupted. Gracefully shutting down..."));
|
|
369
373
|
}
|
|
@@ -373,7 +377,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
373
377
|
await this.web.shutdown();
|
|
374
378
|
}
|
|
375
379
|
catch (e) { }
|
|
376
|
-
|
|
380
|
+
if (!this.config.ci)
|
|
381
|
+
process.exit(0);
|
|
377
382
|
};
|
|
378
383
|
process.once("SIGINT", sigintHandler);
|
|
379
384
|
try {
|
|
@@ -397,6 +402,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
397
402
|
try {
|
|
398
403
|
const fileName = path.basename(filePath);
|
|
399
404
|
this.currentFileName = fileName;
|
|
405
|
+
this.config.testFile = fileName;
|
|
400
406
|
this.localVariables = {};
|
|
401
407
|
this.localBrowserSessions = {};
|
|
402
408
|
this.api.resetState();
|
|
@@ -471,20 +477,24 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
471
477
|
.getPage()
|
|
472
478
|
.waitForLoadState("networkidle", { timeout: 4000 })
|
|
473
479
|
.catch(() => { });
|
|
474
|
-
|
|
480
|
+
if (!this.config.ci)
|
|
481
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
475
482
|
}
|
|
476
483
|
}
|
|
477
484
|
if (this.isAborted)
|
|
478
485
|
break;
|
|
479
486
|
if (!this.isSilent)
|
|
480
487
|
console.log(chalk_1.default.gray("\n[Execution]: Finalizing browser actions..."));
|
|
481
|
-
|
|
488
|
+
if (!this.config.ci)
|
|
489
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
482
490
|
try {
|
|
483
491
|
const baseName = this.currentFileName.replace(".flow", "");
|
|
484
492
|
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
485
493
|
if (!fs.existsSync(mockCloudDir))
|
|
486
494
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
487
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 });
|
|
488
498
|
await this.web.shutdown(targetVideoPath);
|
|
489
499
|
if (!this.isSilent)
|
|
490
500
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -506,6 +516,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
506
516
|
if (!fs.existsSync(mockCloudDir))
|
|
507
517
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
508
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 });
|
|
509
521
|
await this.web.shutdown(targetVideoPath);
|
|
510
522
|
if (!this.isSilent)
|
|
511
523
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -518,7 +530,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
518
530
|
});
|
|
519
531
|
}
|
|
520
532
|
}
|
|
521
|
-
if (!this.isSilent && !this.isAborted) {
|
|
533
|
+
if (!this.config.ci && !this.isSilent && !this.isAborted) {
|
|
522
534
|
if (this.isDebugHalted) {
|
|
523
535
|
console.log(chalk_1.default.cyan("\n[Execution]: Test suite halted by debug flag."));
|
|
524
536
|
}
|
|
@@ -528,6 +540,16 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
528
540
|
}
|
|
529
541
|
}
|
|
530
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
|
+
}
|
|
531
553
|
process.removeListener("SIGINT", sigintHandler);
|
|
532
554
|
}
|
|
533
555
|
}
|
|
@@ -1196,6 +1218,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1196
1218
|
});
|
|
1197
1219
|
}
|
|
1198
1220
|
async startStreaming() {
|
|
1221
|
+
if (this.config.ci)
|
|
1222
|
+
return;
|
|
1199
1223
|
const page = this.web.getPage();
|
|
1200
1224
|
if (this.currentCdpSession) {
|
|
1201
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>;
|