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
package/NATIVE_CI.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Native CI and browser-game candidate
|
|
2
|
+
|
|
3
|
+
This candidate adds native CI and game APIs to both engines. Managed Cloud
|
|
4
|
+
execution remains retired. Existing npm OSS / private Pro archive distribution
|
|
5
|
+
and fallback remain supported. Consumer project migrations are managed separately. Native CI uses protocol 1.
|
|
6
|
+
The public CLI selects compatible Pro engines or logs the reason and falls back
|
|
7
|
+
to OSS. Older Pro CLIs may own the npm binary and do not understand the new
|
|
8
|
+
commands; install both candidates when testing the Pro path.
|
|
9
|
+
|
|
10
|
+
The candidates pin Playwright 1.59.1 in both engines; the native install command
|
|
11
|
+
installs that engine's matching Chromium.
|
|
12
|
+
|
|
13
|
+
## Local candidate check
|
|
14
|
+
|
|
15
|
+
Use a disposable project first. Install the OSS archive, then the Pro archive if
|
|
16
|
+
needed. Both candidates must be installed for testing the new Pro path. The
|
|
17
|
+
currently hosted Cloud download still serves the existing release; this local
|
|
18
|
+
candidate does not change that endpoint or its archive.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install --save-dev /absolute/path/flowstride-1.0.20-rc.6.tgz
|
|
22
|
+
# Optional: install the local matching Pro candidate after project dependencies.
|
|
23
|
+
npm install --no-save /absolute/path/flowstride-pro-1.0.15-rc.5.tgz
|
|
24
|
+
npx --no-install flowstride install
|
|
25
|
+
npx --no-install flowstride run --ci
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Pro continues to require your normal valid developer credentials for licensed
|
|
29
|
+
features. Installing Pro uses `--no-save`: another `npm ci` can remove it. Keep
|
|
30
|
+
project dependency installation before Pro installation. An installed Pro package
|
|
31
|
+
can own `node_modules/.bin/flowstride`; both that entry point and the public OSS
|
|
32
|
+
entry point must be covered when checking fallback.
|
|
33
|
+
|
|
34
|
+
The sample in `examples/native-game` uses a tiny local canvas game. Copy its
|
|
35
|
+
files into a disposable project containing the installed candidates, then run
|
|
36
|
+
the command above. `server.mjs` is the game fixture's HTTP server; it contains no
|
|
37
|
+
Flowstride runner, adapter, worker, or CI orchestration code. A real application
|
|
38
|
+
uses its normal development/start command instead.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
`flowstride.config.json` supports `testDir`, `baseUrl`, `timeout`, `device`
|
|
43
|
+
(`desktop`, `phone`, `landscape`), optional `viewport`, `pluginsDir`,
|
|
44
|
+
`artifactsDir`, `recordVideo` (boolean), `browserErrors` (`fail` or `record`), and
|
|
45
|
+
`webServer` (`command`, local readiness `url`, optional `cwd` and `timeout`).
|
|
46
|
+
Malformed configuration and unsafe output paths fail before execution.
|
|
47
|
+
|
|
48
|
+
Native CI is selected by `--ci` or a recognized CI environment. It runs without
|
|
49
|
+
a dashboard server, returns nonzero for failed/incomplete/crashed execution,
|
|
50
|
+
and writes latest `summary.json`/`junit.xml` reports at the configured artifact
|
|
51
|
+
root. Media and attachments go into a unique run directory referenced by the
|
|
52
|
+
summary, so local reruns retain history without mixing evidence.
|
|
53
|
+
SIGINT/SIGTERM produce interrupted results. The run owns its optional local
|
|
54
|
+
server and refuses an already-serving readiness URL.
|
|
55
|
+
|
|
56
|
+
Cloud reporting is off by default in native CI. `cloudSync: true` explicitly
|
|
57
|
+
allows existing authenticated reporting; matching Cloud deployment and configured media storage are required. Reporting and
|
|
58
|
+
video status are written separately in the summary and `.flowstride/cloud-sync.json`;
|
|
59
|
+
a reporting failure does not turn a passed test into a failed test. Normal interactive reporting and the restored
|
|
60
|
+
Cloud URL overrides remain separate from retired managed execution.
|
|
61
|
+
|
|
62
|
+
`npx flowstride ci init github` generates a workflow explicitly, never during
|
|
63
|
+
installation, and refuses to overwrite it. It assumes an npm project with a
|
|
64
|
+
committed lockfile. Other package managers and monorepos can use the composite
|
|
65
|
+
action's install-command and working-directory inputs. The composite action is
|
|
66
|
+
in the OSS repository's `action/` directory and becomes remotely usable only
|
|
67
|
+
once that source is pushed. Pin its commit when configuring a consumer workflow.
|
|
68
|
+
Pro jobs pass a developer token as a secret; dependency installation precedes
|
|
69
|
+
Pro login/install, matching-browser installation, execution, and artifact upload.
|
|
70
|
+
|
|
71
|
+
## Native game actions
|
|
72
|
+
|
|
73
|
+
Register the built-in alias with `let game = "flowstride:game"`. It requires no
|
|
74
|
+
custom plugin file. Available actions:
|
|
75
|
+
|
|
76
|
+
- `tap`: canvas selector, x/y; relative coordinates by default, or `unit: "pixels"`.
|
|
77
|
+
- `drag`/`swipe`: canvas, from/to coordinates, optional duration and input type.
|
|
78
|
+
- `key`: key name and optional hold duration.
|
|
79
|
+
- `checkpoint`: named screenshot, optionally of one canvas.
|
|
80
|
+
- `snapshot` and `expectVisual`: canvas comparison with explicit minChange/maxChange.
|
|
81
|
+
- `state`/`expectState`: exposed state, optional dotted path and expected equals.
|
|
82
|
+
- `snapshotState`/`expectPreserved`: compare selected state across actions/reloads.
|
|
83
|
+
- `fixture`: a named fixture registered by the application in explicit test mode.
|
|
84
|
+
- `select`: a stable object ID resolved by the game bridge, then real pointer input.
|
|
85
|
+
|
|
86
|
+
Visual checks establish pixel differences, not gameplay correctness; pair them
|
|
87
|
+
with state or UI assertions. Mobile profiles emulate Chromium devices, not
|
|
88
|
+
physical phones. Touch dragging currently uses Chromium CDP.
|
|
89
|
+
|
|
90
|
+
For a generic browser game, import `registerGame` from
|
|
91
|
+
`flowstride/dist/game/bridge.js` and supply readState and optional test-only
|
|
92
|
+
fixtures. The function manages the browser bridge; no global setup is required
|
|
93
|
+
in the test runner.
|
|
94
|
+
|
|
95
|
+
## Three.js integration
|
|
96
|
+
|
|
97
|
+
Import `registerThreeGame` from `flowstride/dist/game/three-bridge.js` in the game
|
|
98
|
+
bundle. Register the live scene, camera, canvas, the application's own Vector3
|
|
99
|
+
and Raycaster constructors, and an optional objects map keyed by stable IDs.
|
|
100
|
+
Alternatively, assign `userData.flowstrideId` to scene objects. Optional local
|
|
101
|
+
pick points use `userData.flowstridePickPoint: [x, y, z]`.
|
|
102
|
+
|
|
103
|
+
The bridge projects the live object and raycasts the live scene; hidden,
|
|
104
|
+
occluded, off-screen, covered or non-pickable objects fail explicitly. It creates
|
|
105
|
+
no renderer or animation loop. Dispose the bridge when the scene is replaced.
|
|
106
|
+
|
|
107
|
+
Optional `readState` provides observable state. `fixtures` are available only
|
|
108
|
+
when the application explicitly registers with `testMode: true`; use that
|
|
109
|
+
setting in dedicated test builds, never public production builds. Naija Throne
|
|
110
|
+
still defines campaign-specific fixtures/rules, using these supported APIs.
|
|
111
|
+
|
|
112
|
+
Custom plugins now receive `sdk.game`, `sdk.browser.page()`, and `sdk.run`
|
|
113
|
+
(device, artifact directory and attach method), alongside the existing SDK.
|
|
114
|
+
They need no global bridge or internal Flowstride class imports.
|
|
115
|
+
|
|
116
|
+
## Validation before release
|
|
117
|
+
|
|
118
|
+
Build both packages and run `npm run test:native`. For the optional live Three.js
|
|
119
|
+
fixture set `FLOWSTRIDE_THREE_BUILD` to a Three.js build directory containing
|
|
120
|
+
three.module.js and three.core.js. OSS CLI handoff tests use
|
|
121
|
+
`FLOWSTRIDE_PRO_SOURCE` pointing at the built Pro package.
|
|
122
|
+
|
|
123
|
+
Run the real archive install/handoff harness against both candidates, including
|
|
124
|
+
missing/broken Pro and revoked-token fallback. Validate GitHub's Linux execution,
|
|
125
|
+
real licensed download after local acceptance.
|
|
126
|
+
No push, npm publication, Cloud archive replacement or Naija Throne migration
|
|
127
|
+
is performed by building these candidates.
|
|
128
|
+
|
|
129
|
+
## Combined candidate behavior
|
|
130
|
+
|
|
131
|
+
Node.js 22.12 or newer is required. Flow discovery is recursive; `.first.flow`
|
|
132
|
+
files run before ordinary files. Reports identify nested flows by their relative
|
|
133
|
+
paths, and same-named videos receive distinct filenames. A failed scenario skips
|
|
134
|
+
its remaining steps and continues to later scenarios. `--bail` stops later work;
|
|
135
|
+
Pro executes bail runs sequentially. `only.Scenario:` selects that file's focus
|
|
136
|
+
scenario and report planning uses the same selection.
|
|
137
|
+
|
|
138
|
+
Configuration, plugin, environment, upload, vault and output paths must stay
|
|
139
|
+
inside the project, including resolved symlinks. Move shared fixtures into the
|
|
140
|
+
project rather than referring to external files. `FLOWSTRIDE_API_URL` takes
|
|
141
|
+
precedence over `NEXTJS_WEBHOOK_URL`; the legacy completion suffix is normalized.
|
|
142
|
+
Managed runner commands remain retired.
|
|
143
|
+
|
|
144
|
+
Logs redact credential fields, known generated secrets and signed capabilities.
|
|
145
|
+
Redaction cannot guarantee removal of arbitrary application data or visible
|
|
146
|
+
secrets in screenshots/videos. Review artifact access and test data accordingly.
|
|
147
|
+
|
|
148
|
+
The Pro installer rejects unsafe or incomplete archives, downloads above 32 MiB,
|
|
149
|
+
and checksum mismatches. Older Cloud servers without checksum headers remain
|
|
150
|
+
compatible and emit a warning; `FLOWSTRIDE_PRO_SHA256` can pin an expected digest.
|
|
151
|
+
On failed npm installation the previous Pro directory and CLI launchers are
|
|
152
|
+
restored. This is not a transaction over every project dependency npm may alter;
|
|
153
|
+
use a disposable project for candidate acceptance.
|
|
154
|
+
|
|
155
|
+
## Dashboard tab history
|
|
156
|
+
|
|
157
|
+
Completed test tabs restore that file's last captured step by default. Selecting
|
|
158
|
+
an earlier step remembers its history cursor separately for each file. A sidebar
|
|
159
|
+
step also selects the owning tab. Return to Live applies only to the selected
|
|
160
|
+
file, and a new suite/rerun clears old cursors and screens. Runner console and
|
|
161
|
+
network panels show the selected file's records; the full Logs view retains
|
|
162
|
+
suite-wide history. Flows/steps without screenshots show an explicit empty
|
|
163
|
+
state rather than another flow's screen.
|
|
164
|
+
|
|
165
|
+
Run `npm run test:dashboard:unit` for the Zustand store/history unit tests and
|
|
166
|
+
`npm run test:dashboard` for those tests plus the real Chromium dashboard
|
|
167
|
+
regression. The browser regression runs real local browser flows, then replays
|
|
168
|
+
their worker events over WebSocket into the built dashboard. It also supplies
|
|
169
|
+
explicit API-only/background-event fixtures. No live Cloud is required.
|
|
170
|
+
|
|
171
|
+
### Local cURL debugging
|
|
172
|
+
|
|
173
|
+
The interactive local dashboard keeps its cURL preview [REDACTED]. Copy raw cURL
|
|
174
|
+
uses the original request held separately in local memory, including its headers
|
|
175
|
+
and body. Other log fields stay redacted. Native CI and Cloud reporting continue redacting cURL and
|
|
176
|
+
credentials. Raw cURL is not restored for already-redacted historical runs.
|
|
177
|
+
The interactive runner binds to 127.0.0.1 and restricts HTTP Host/Origin and
|
|
178
|
+
WebSocket Origin to its own loopback address and port. Raw commands remain in
|
|
179
|
+
local debugger memory and IPC; they are not written to CI or Cloud reports.
|
|
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.WebAdapter = void 0;
|
|
40
|
+
const projectPaths_1 = require("../../utils/projectPaths");
|
|
41
|
+
const playwright_1 = require("playwright");
|
|
40
42
|
const fs = __importStar(require("fs"));
|
|
41
43
|
const path = __importStar(require("path"));
|
|
42
44
|
const playwright_extra_1 = require("playwright-extra");
|
|
@@ -44,6 +46,7 @@ const puppeteer_extra_plugin_stealth_1 = __importDefault(require("puppeteer-extr
|
|
|
44
46
|
playwright_extra_1.chromium.use((0, puppeteer_extra_plugin_stealth_1.default)());
|
|
45
47
|
class WebAdapter {
|
|
46
48
|
config;
|
|
49
|
+
pageErrors = [];
|
|
47
50
|
browser = null;
|
|
48
51
|
context = null;
|
|
49
52
|
page = null;
|
|
@@ -75,20 +78,23 @@ class WebAdapter {
|
|
|
75
78
|
}
|
|
76
79
|
this.browser = await playwright_extra_1.chromium.launch({
|
|
77
80
|
headless: this.config.headless,
|
|
81
|
+
// Native CI finalizes videos before closing the browser on cancellation.
|
|
82
|
+
...(this.config.ci ? { handleSIGINT: false, handleSIGTERM: false } : {}),
|
|
78
83
|
args: launchArgs,
|
|
79
84
|
});
|
|
80
|
-
const videoDir =
|
|
85
|
+
const videoDir = (0, projectPaths_1.projectPath)(process.cwd(), path.join('.flowstride', 'videos'));
|
|
81
86
|
if (!fs.existsSync(videoDir))
|
|
82
87
|
fs.mkdirSync(videoDir, { recursive: true });
|
|
83
|
-
const
|
|
88
|
+
const profile = this.config.device === 'phone' || this.config.device === 'landscape'
|
|
89
|
+
? { ...playwright_1.devices['Pixel 7'], viewport: this.config.device === 'landscape' ? { width: 844, height: 390 } : { width: 390, height: 844 } }
|
|
90
|
+
: { viewport: { width: 1280, height: 800 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' };
|
|
91
|
+
const viewport = this.config.viewport || profile.viewport;
|
|
84
92
|
this.context = await this.browser.newContext({
|
|
85
|
-
viewport
|
|
86
|
-
|
|
87
|
-
recordVideo: {
|
|
88
|
-
dir: videoDir,
|
|
89
|
-
size: { width: 1280, height: 800 },
|
|
90
|
-
},
|
|
93
|
+
...profile, viewport,
|
|
94
|
+
...(this.config.recordVideo === false ? {} : { recordVideo: { dir: videoDir, size: viewport } }),
|
|
91
95
|
});
|
|
96
|
+
this.pageErrors = [];
|
|
97
|
+
this.context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
|
|
92
98
|
this.context.on("dialog", async (dialog) => {
|
|
93
99
|
if (this.dialogHandler) {
|
|
94
100
|
await this.dialogHandler(dialog);
|
|
@@ -120,7 +126,7 @@ class WebAdapter {
|
|
|
120
126
|
};
|
|
121
127
|
}
|
|
122
128
|
async injectAudio(filePath) {
|
|
123
|
-
const absolutePath =
|
|
129
|
+
const absolutePath = (0, projectPaths_1.projectPath)(process.cwd(), filePath);
|
|
124
130
|
if (!fs.existsSync(absolutePath)) {
|
|
125
131
|
throw new Error(`Audio file not found at: ${absolutePath}`);
|
|
126
132
|
}
|
|
@@ -269,7 +275,8 @@ class WebAdapter {
|
|
|
269
275
|
});
|
|
270
276
|
}
|
|
271
277
|
synthesizeCurl(method, url, headers, postData) {
|
|
272
|
-
|
|
278
|
+
const safeUrl = url.replace(/'/g, "'\\''");
|
|
279
|
+
let curl = `curl -X ${method.toUpperCase()} '${safeUrl}'`;
|
|
273
280
|
for (const [key, value] of Object.entries(headers)) {
|
|
274
281
|
if (key.startsWith(":"))
|
|
275
282
|
continue;
|
|
@@ -479,10 +486,10 @@ class WebAdapter {
|
|
|
479
486
|
await targetLoc.evaluate((node) => node.blur());
|
|
480
487
|
let actualValue = "";
|
|
481
488
|
try {
|
|
482
|
-
actualValue = await targetLoc.inputValue({ timeout:
|
|
489
|
+
actualValue = await targetLoc.inputValue({ timeout: this.config.timeout });
|
|
483
490
|
}
|
|
484
491
|
catch {
|
|
485
|
-
actualValue = (await targetLoc.textContent({ timeout:
|
|
492
|
+
actualValue = (await targetLoc.textContent({ timeout: this.config.timeout })) || "";
|
|
486
493
|
}
|
|
487
494
|
const cleanActual = actualValue.replace(/[^a-zA-Z0-9]/g, "");
|
|
488
495
|
const cleanExpected = value.replace(/[^a-zA-Z0-9]/g, "");
|
|
@@ -609,16 +616,15 @@ class WebAdapter {
|
|
|
609
616
|
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
610
617
|
await targetLoc.waitFor({ state: "visible" });
|
|
611
618
|
await targetLoc.scrollIntoViewIfNeeded();
|
|
612
|
-
const absolutePath =
|
|
619
|
+
const absolutePath = (0, projectPaths_1.projectPath)(process.cwd(), filePath);
|
|
613
620
|
if (!fs.existsSync(absolutePath)) {
|
|
614
621
|
throw new Error(`File Upload Error: Could not find the file at "${absolutePath}". Please ensure the file exists in your project directory.`);
|
|
615
622
|
}
|
|
616
623
|
try {
|
|
617
|
-
const
|
|
618
|
-
timeout:
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const fileChooser = await fileChooserPromise;
|
|
624
|
+
const [fileChooser] = await Promise.all([
|
|
625
|
+
this.page.waitForEvent('filechooser', { timeout: this.config.timeout }),
|
|
626
|
+
targetLoc.click({ timeout: this.config.timeout }),
|
|
627
|
+
]);
|
|
622
628
|
await fileChooser.setFiles(absolutePath);
|
|
623
629
|
}
|
|
624
630
|
catch (e) {
|
|
@@ -0,0 +1,77 @@
|
|
|
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.isCIEnvironment = isCIEnvironment;
|
|
7
|
+
exports.loadNativeConfig = loadNativeConfig;
|
|
8
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
function isCIEnvironment() {
|
|
12
|
+
return ['FLOWSTRIDE_NATIVE_CI', 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL', 'FLOWSTRIDE_API_KEY']
|
|
13
|
+
.some(key => !!process.env[key] && process.env[key] !== 'false' && process.env[key] !== '0');
|
|
14
|
+
}
|
|
15
|
+
function loadNativeConfig(root, options) {
|
|
16
|
+
const file = (0, projectPaths_1.projectPath)(root, options.config || 'flowstride.config.json');
|
|
17
|
+
let input = {};
|
|
18
|
+
if (node_fs_1.default.existsSync(file)) {
|
|
19
|
+
input = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
|
|
20
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
21
|
+
throw new Error('Flowstride configuration must be an object.');
|
|
22
|
+
}
|
|
23
|
+
else if (options.config)
|
|
24
|
+
throw new Error(`Configuration not found: ${file}`);
|
|
25
|
+
const config = { ...input, projectRoot: node_path_1.default.resolve(root), timeout: input.timeout ?? 30000, headless: input.headless ?? true,
|
|
26
|
+
ci: !!options.ci || isCIEnvironment(), bail: options.bail || input.bail || false, device: options.device ?? input.device ?? 'desktop',
|
|
27
|
+
artifactsDir: input.artifactsDir ?? 'flowstride-artifacts', testDir: input.testDir ?? 'flows',
|
|
28
|
+
defaultDialogBehavior: input.defaultDialogBehavior ?? 'dismiss',
|
|
29
|
+
recordVideo: input.recordVideo ?? true, browserErrors: input.browserErrors ?? 'fail',
|
|
30
|
+
isCloudSyncActive: options.isCloudSyncActive ?? false, machineToken: options.machineToken ?? null,
|
|
31
|
+
workspaceId: options.workspaceId ?? null, manifest: options.manifest ?? null };
|
|
32
|
+
if (config.ci && input.cloudSync !== true)
|
|
33
|
+
config.isCloudSyncActive = false;
|
|
34
|
+
if (options.headless || config.ci)
|
|
35
|
+
config.headless = true;
|
|
36
|
+
else if (options.headed)
|
|
37
|
+
config.headless = false;
|
|
38
|
+
if (process.env.BASE_URL)
|
|
39
|
+
config.baseUrl = process.env.BASE_URL;
|
|
40
|
+
if (!Number.isSafeInteger(config.timeout) || config.timeout < 1)
|
|
41
|
+
throw new Error('timeout must be a positive integer.');
|
|
42
|
+
if (!['desktop', 'phone', 'landscape'].includes(config.device))
|
|
43
|
+
throw new Error('device must be desktop, phone, or landscape.');
|
|
44
|
+
for (const key of ['headless', 'recordVideo', 'bail'])
|
|
45
|
+
if (config[key] !== undefined && typeof config[key] !== 'boolean')
|
|
46
|
+
throw new Error(`${key} must be boolean.`);
|
|
47
|
+
if (!['fail', 'record'].includes(config.browserErrors))
|
|
48
|
+
throw new Error('browserErrors must be fail or record.');
|
|
49
|
+
for (const key of ['artifactsDir', 'testDir', 'pluginsDir']) {
|
|
50
|
+
if (config[key] === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
if (typeof config[key] !== 'string' || !config[key].trim())
|
|
53
|
+
throw new Error(`${key} must be a nonempty path.`);
|
|
54
|
+
const resolved = (0, projectPaths_1.projectPath)(root, config[key]);
|
|
55
|
+
const relative = node_path_1.default.relative(root, resolved);
|
|
56
|
+
if ((key === 'artifactsDir' && !relative) || relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(relative))
|
|
57
|
+
throw new Error(`${key} must be a directory inside the project.`);
|
|
58
|
+
config[key] = resolved;
|
|
59
|
+
}
|
|
60
|
+
if (config.viewport && (!Number.isSafeInteger(config.viewport.width) || config.viewport.width < 1 || !Number.isSafeInteger(config.viewport.height) || config.viewport.height < 1))
|
|
61
|
+
throw new Error('viewport requires positive integer width and height.');
|
|
62
|
+
if (config.webServer) {
|
|
63
|
+
const server = config.webServer;
|
|
64
|
+
if (typeof server.command !== 'string' || !server.command.trim() || typeof server.url !== 'string')
|
|
65
|
+
throw new Error('webServer requires command and url.');
|
|
66
|
+
const url = new URL(server.url);
|
|
67
|
+
if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
|
68
|
+
throw new Error('webServer readiness URL must use local HTTP(S).');
|
|
69
|
+
if (server.timeout !== undefined && (!Number.isSafeInteger(server.timeout) || server.timeout < 1))
|
|
70
|
+
throw new Error('webServer.timeout must be positive.');
|
|
71
|
+
server.cwd = (0, projectPaths_1.projectPath)(root, server.cwd || '.');
|
|
72
|
+
const relative = node_path_1.default.relative(root, server.cwd);
|
|
73
|
+
if (relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(relative))
|
|
74
|
+
throw new Error('webServer.cwd must be inside the project.');
|
|
75
|
+
}
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withDeadline = withDeadline;
|
|
4
|
+
/** Bound asynchronous plugin work while observing late rejections safely. */
|
|
5
|
+
async function withDeadline(operation, timeout) {
|
|
6
|
+
let timer;
|
|
7
|
+
try {
|
|
8
|
+
return await Promise.race([Promise.resolve(operation), new Promise((_resolve, reject) => {
|
|
9
|
+
timer = setTimeout(() => reject(new Error(`Flowstride step exceeded its ${timeout}ms timeout.`)), timeout);
|
|
10
|
+
})]);
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
if (timer)
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function initCI(provider: string): void;
|
package/dist/ci/init.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.initCI = initCI;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
function initCI(provider) {
|
|
11
|
+
if (provider !== 'github')
|
|
12
|
+
throw new Error('Supported provider: github.');
|
|
13
|
+
const file = node_path_1.default.resolve('.github/workflows/flowstride.yml');
|
|
14
|
+
if (node_fs_1.default.existsSync(file))
|
|
15
|
+
throw new Error('Flowstride workflow already exists; it was not overwritten.');
|
|
16
|
+
const artifacts = node_path_1.default.relative(process.cwd(), (0, config_1.loadNativeConfig)(process.cwd(), {}).artifactsDir);
|
|
17
|
+
const parent = node_path_1.default.dirname(file);
|
|
18
|
+
node_fs_1.default.mkdirSync(parent, { recursive: true });
|
|
19
|
+
node_fs_1.default.writeFileSync(file, `name: Flowstride\non:\n push:\n pull_request:\n workflow_dispatch:\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 20\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262\n with:\n persist-credentials: false\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020\n with:\n node-version: 24\n cache: npm\n - run: npm ci\n - name: Install matching browser dependencies\n run: npx --no-install flowstride install --with-deps\n - name: Run native Flowstride\n run: npx --no-install flowstride run --ci\n - name: Retain test evidence\n if: always()\n uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02\n with:\n name: flowstride-results\n path: ${JSON.stringify(artifacts + "/")}\n retention-days: 14\n`, { flag: 'wx' });
|
|
20
|
+
console.log(`Created ${file}. Commit it when ready. This template uses the project-locked Flowstride installation.`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FlowstrideConfig } from '../types';
|
|
2
|
+
export interface NativeRunResult {
|
|
3
|
+
protocol: 1;
|
|
4
|
+
engine: string;
|
|
5
|
+
status: 'passed' | 'failed' | 'interrupted';
|
|
6
|
+
duration: number;
|
|
7
|
+
artifactsDir: string;
|
|
8
|
+
files: string[];
|
|
9
|
+
steps: any[];
|
|
10
|
+
errors: string[];
|
|
11
|
+
metrics: {
|
|
12
|
+
total: number;
|
|
13
|
+
passed: number;
|
|
14
|
+
failed: number;
|
|
15
|
+
skipped: number;
|
|
16
|
+
incomplete: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare function runNativeCI(worker: any, orchestrator: any, target: string, config: FlowstrideConfig, engine: string): Promise<NativeRunResult>;
|
|
@@ -0,0 +1,203 @@
|
|
|
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.runNativeCI = runNativeCI;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
const promises_1 = require("node:timers/promises");
|
|
13
|
+
const lexer_1 = require("../parser/lexer");
|
|
14
|
+
const parser_1 = require("../parser/parser");
|
|
15
|
+
function safeName(name) { return name.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 120) + '-' + (0, node_crypto_1.createHash)('sha256').update(name).digest('hex').slice(0, 12); }
|
|
16
|
+
function xml(value) { return String(value).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[<>&"']/g, char => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[char])); }
|
|
17
|
+
async function runNativeCI(worker, orchestrator, target, config, engine) {
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
const artifactRoot = config.artifactsDir;
|
|
20
|
+
node_fs_1.default.mkdirSync(artifactRoot, { recursive: true });
|
|
21
|
+
const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(artifactRoot, 'run-'));
|
|
22
|
+
config.artifactsDir = directory;
|
|
23
|
+
const steps = new Map();
|
|
24
|
+
const completed = new Set();
|
|
25
|
+
const errors = [];
|
|
26
|
+
const planned = new Map();
|
|
27
|
+
let bailed = false;
|
|
28
|
+
let expected = 0;
|
|
29
|
+
let files = [];
|
|
30
|
+
let server;
|
|
31
|
+
let interrupted = false;
|
|
32
|
+
const interrupt = () => { interrupted = true; worker.abort(); orchestrator.abort?.(); };
|
|
33
|
+
const event = (data) => {
|
|
34
|
+
if (!data?.payload)
|
|
35
|
+
return;
|
|
36
|
+
const p = data.payload;
|
|
37
|
+
if (data.type === 'BAIL')
|
|
38
|
+
bailed = true;
|
|
39
|
+
if (data.type === 'LOG_EVENT' && p.status === 'error') {
|
|
40
|
+
const active = [...steps.values()].reverse().find(step => step.fileName === p.fileName && step.status === 'running');
|
|
41
|
+
if (active)
|
|
42
|
+
active.error = p.details?.rawError || p.summary;
|
|
43
|
+
}
|
|
44
|
+
if (data.type === 'STEP_UPDATE' || data.type === 'STEP_ADD') {
|
|
45
|
+
const key = `${p.fileName}:${p.id}`;
|
|
46
|
+
steps.set(key, { ...steps.get(key), ...p, screenshot: undefined });
|
|
47
|
+
if (p.status === 'failed' && typeof p.screenshot === 'string') {
|
|
48
|
+
const match = p.screenshot.match(/^data:image\/(png|jpeg);base64,(.+)$/s) || (/^[A-Za-z0-9+/=\r\n]+$/.test(p.screenshot) ? ['', 'jpeg', p.screenshot] : null);
|
|
49
|
+
if (match)
|
|
50
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(directory, `${safeName(p.fileName)}-${safeName(p.id)}.${match[1] === 'jpeg' ? 'jpg' : 'png'}`), Buffer.from(match[2], 'base64'));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (data.type === 'FILE_COMPLETE' || data.type === 'FILE_FAILED')
|
|
54
|
+
completed.add(p.fileName);
|
|
55
|
+
if (data.type === 'FILE_FAILED' && ![...steps.values()].some(step => step.fileName === p.fileName && step.status === 'failed'))
|
|
56
|
+
errors.push(`${p.fileName}: ${p.error || 'Execution failed'}`);
|
|
57
|
+
if (data.type === 'BROWSER_ERROR' && config.browserErrors !== 'record')
|
|
58
|
+
errors.push(`Browser: ${p.message}`);
|
|
59
|
+
if (data.type === 'CLOUD_SYNC')
|
|
60
|
+
config.cloudSyncResult = p;
|
|
61
|
+
if (data.type === 'WORKER_ERROR')
|
|
62
|
+
errors.push(p.message || 'Worker failed');
|
|
63
|
+
};
|
|
64
|
+
// Modern orchestrators forward child-process events here; the worker listener
|
|
65
|
+
// also supports older compatible Pro packages. Step maps make duplicates harmless.
|
|
66
|
+
config.onRunEvent = event;
|
|
67
|
+
if (orchestrator.nativeEventProtocol !== 1)
|
|
68
|
+
worker.on('worker_event', event);
|
|
69
|
+
process.on('SIGINT', interrupt);
|
|
70
|
+
process.on('SIGTERM', interrupt);
|
|
71
|
+
try {
|
|
72
|
+
files = (0, projectPaths_1.discoverFlows)(target, config.projectRoot || process.cwd());
|
|
73
|
+
config.flowRoot = node_fs_1.default.statSync(target).isDirectory() ? target : node_path_1.default.dirname(target);
|
|
74
|
+
if (!files.length)
|
|
75
|
+
throw new Error('No .flow tests found.');
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const features = (0, projectPaths_1.selectedFeatures)(new parser_1.Parser(new lexer_1.Lexer(node_fs_1.default.readFileSync(file, 'utf8')).tokenise()).parse());
|
|
78
|
+
for (const feature of features)
|
|
79
|
+
for (const scenario of feature.scenarios)
|
|
80
|
+
for (const step of scenario.steps) {
|
|
81
|
+
const fileName = (0, projectPaths_1.flowIdentity)(file, config.flowRoot);
|
|
82
|
+
planned.set(`${fileName}:${step.id}`, { id: step.id, description: step.blockDescription, command: step.action, fileName, status: 'skipped', reason: 'bail' });
|
|
83
|
+
}
|
|
84
|
+
expected += features.reduce((n, feature) => n + feature.scenarios.reduce((m, scenario) => m + scenario.steps.length, 0), 0);
|
|
85
|
+
}
|
|
86
|
+
if (!expected)
|
|
87
|
+
throw new Error('No executable test steps found.');
|
|
88
|
+
if (config.webServer) {
|
|
89
|
+
const settings = config.webServer;
|
|
90
|
+
const probe = async () => { try {
|
|
91
|
+
return (await fetch(settings.url, { signal: AbortSignal.timeout(1000) })).ok;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
} };
|
|
96
|
+
if (await probe())
|
|
97
|
+
throw new Error('webServer readiness URL is already serving; refusing to test an unowned server.');
|
|
98
|
+
const output = node_fs_1.default.openSync(node_path_1.default.join(directory, 'web-server.log'), 'a');
|
|
99
|
+
try {
|
|
100
|
+
server = (0, node_child_process_1.spawn)(settings.command, { cwd: settings.cwd, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', output, output] });
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
node_fs_1.default.closeSync(output);
|
|
104
|
+
}
|
|
105
|
+
let spawnError;
|
|
106
|
+
server.on('error', error => { spawnError = error; });
|
|
107
|
+
const deadline = Date.now() + (settings.timeout ?? 30000);
|
|
108
|
+
while (!(await probe())) {
|
|
109
|
+
if (interrupted)
|
|
110
|
+
throw new Error('Run interrupted during server startup.');
|
|
111
|
+
if (spawnError)
|
|
112
|
+
throw spawnError;
|
|
113
|
+
if (server.exitCode !== null || server.signalCode !== null)
|
|
114
|
+
throw new Error('webServer exited before readiness.');
|
|
115
|
+
if (Date.now() > deadline)
|
|
116
|
+
throw new Error('webServer readiness timed out.');
|
|
117
|
+
await (0, promises_1.setTimeout)(100);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (interrupted)
|
|
121
|
+
throw new Error('Run interrupted before execution.');
|
|
122
|
+
const ok = await orchestrator.start(target);
|
|
123
|
+
if (ok === false && ![...steps.values()].some(step => step.status === 'failed'))
|
|
124
|
+
errors.push('Engine reported a failed run.');
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
try {
|
|
131
|
+
await worker.shutdown();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
errors.push(`Cleanup failed: ${String(error)}`);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await orchestrator.close?.();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
errors.push(`Orchestrator cleanup failed: ${String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
if (server?.pid) {
|
|
143
|
+
const stop = (signal) => { try {
|
|
144
|
+
process.kill(process.platform === 'win32' ? server.pid : -server.pid, signal);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error.code !== 'ESRCH')
|
|
148
|
+
errors.push(`Server cleanup: ${error.message}`);
|
|
149
|
+
} };
|
|
150
|
+
stop('SIGTERM');
|
|
151
|
+
await Promise.race([new Promise(resolve => server.once('exit', () => resolve())), (0, promises_1.setTimeout)(1000)]);
|
|
152
|
+
// Terminate remaining descendants even if the shell exited first.
|
|
153
|
+
stop('SIGKILL');
|
|
154
|
+
}
|
|
155
|
+
worker.off('worker_event', event);
|
|
156
|
+
delete config.onRunEvent;
|
|
157
|
+
process.off('SIGINT', interrupt);
|
|
158
|
+
process.off('SIGTERM', interrupt);
|
|
159
|
+
}
|
|
160
|
+
if (interrupted)
|
|
161
|
+
errors.push('Run interrupted.');
|
|
162
|
+
if (bailed)
|
|
163
|
+
for (const [key, step] of planned)
|
|
164
|
+
if (!steps.has(key))
|
|
165
|
+
steps.set(key, step);
|
|
166
|
+
const results = [...steps.values()];
|
|
167
|
+
const terminal = results.filter(step => ['passed', 'failed', 'skipped', 'not_tested'].includes(step.status));
|
|
168
|
+
const incomplete = Math.max(0, expected - terminal.length);
|
|
169
|
+
for (const file of files)
|
|
170
|
+
if (!bailed && !completed.has((0, projectPaths_1.flowIdentity)(file, config.flowRoot)))
|
|
171
|
+
errors.push(`Incomplete file: ${(0, projectPaths_1.flowIdentity)(file, config.flowRoot)}`);
|
|
172
|
+
if (results.some(step => !['passed', 'failed', 'skipped', 'not_tested'].includes(step.status)))
|
|
173
|
+
errors.push('Unfinished test steps.');
|
|
174
|
+
if (terminal.length > expected)
|
|
175
|
+
errors.push('Engine produced an unexpected step count.');
|
|
176
|
+
const metrics = { total: expected, passed: results.filter(s => s.status === 'passed').length,
|
|
177
|
+
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 };
|
|
180
|
+
// Existing workers finalize videos into these locations. Copy only run files,
|
|
181
|
+
// never the project directory or credentials.
|
|
182
|
+
if (config.recordVideo !== false)
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
for (const folder of ['artifacts', 'mock-cloud']) {
|
|
185
|
+
const video = node_path_1.default.join(process.cwd(), '.flowstride', folder, (0, projectPaths_1.videoFileName)((0, projectPaths_1.flowIdentity)(file, config.flowRoot)));
|
|
186
|
+
if (node_fs_1.default.existsSync(video) && node_fs_1.default.statSync(video).mtimeMs >= started) {
|
|
187
|
+
node_fs_1.default.copyFileSync(video, node_path_1.default.join(directory, node_path_1.default.basename(video)));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
config.artifactsDir = artifactRoot;
|
|
193
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'summary.json'), JSON.stringify(result, null, 2));
|
|
194
|
+
const cases = results.map(step => `<testcase classname="${xml(step.fileName)}" name="${xml(step.description || step.id)}">${step.status === 'failed' ? `<failure message="${xml(step.error || 'Test step failed')}"/>` : ['skipped', 'not_tested'].includes(step.status) ? '<skipped/>' : ''}</testcase>`);
|
|
195
|
+
for (const error of errors)
|
|
196
|
+
cases.push(`<testcase name="execution"><error message="${xml(error)}"/></testcase>`);
|
|
197
|
+
if (incomplete)
|
|
198
|
+
cases.push(`<testcase name="completion"><failure message="${incomplete} incomplete steps"/></testcase>`);
|
|
199
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'junit.xml'), `<?xml version="1.0"?><testsuite name="Flowstride" tests="${cases.length}" failures="${metrics.failed + (incomplete ? 1 : 0)}" errors="${errors.length}" skipped="${metrics.skipped}">${cases.join('')}</testsuite>`);
|
|
200
|
+
if (process.env.GITHUB_STEP_SUMMARY)
|
|
201
|
+
node_fs_1.default.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n### Flowstride · ${config.device}\nResult: **${result.status}** (${engine})\n\nPassed: ${metrics.passed}; failed: ${metrics.failed}; skipped: ${metrics.skipped}; incomplete: ${metrics.incomplete}.\n`);
|
|
202
|
+
return result;
|
|
203
|
+
}
|