@itmetsam/samurai-framework 0.21.0 → 0.21.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/README.md +9 -4
- package/dist/browser/browser.js +19 -3
- package/dist/browser/chrome-launch.d.ts +2 -0
- package/dist/browser/chrome-launch.js +5 -0
- package/dist/config/mask.d.ts +6 -2
- package/dist/config/mask.js +4 -1
- package/dist/network/network-tracker.d.ts +17 -1
- package/dist/network/network-tracker.js +63 -18
- package/dist/recorder/toolbar/actions/pick.d.ts +3 -0
- package/dist/recorder/toolbar/actions/pick.js +11 -1
- package/dist/recorder/toolbar/actions/save-output.d.ts +11 -5
- package/dist/recorder/toolbar/actions/save-output.js +50 -23
- package/dist/recorder/toolbar/controller.js +3 -1
- package/dist/recorder/toolbar/protocol.d.ts +23 -0
- package/dist/recorder/toolbar/source/fields.js +4 -1
- package/dist/recorder/toolbar/source/picking.js +1 -1
- package/dist/recorder/toolbar/source/recorder-side.js +8 -0
- package/dist/recorder/toolbar/source/views-panels.js +14 -2
- package/dist/recorder/toolbar/source.d.ts +2 -1
- package/dist/recorder/toolbar/source.js +2 -1
- package/dist/recorder/toolbar/types.d.ts +2 -2
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A browser test framework written in TypeScript. It drives a real browser over the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) protocol, with no Playwright or Puppeteer underneath. Firefox speaks BiDi itself; Chrome is driven through its own `chromedriver`, which the framework starts for you.
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@itmetsam/samurai-framework)
|
|
6
|
+
|
|
7
|
+
> This repository is a read-only mirror of `packages/framework` in the SAMURAI monorepo. Issues are welcome; pull requests aren't merged here. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
6
8
|
|
|
7
9
|
```ts
|
|
8
10
|
import { expect, test } from "../api.js";
|
|
@@ -56,6 +58,8 @@ cd samurai-framework
|
|
|
56
58
|
bun install
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
The clone is the mirror described in [CONTRIBUTING.md](CONTRIBUTING.md). It has no lockfile of its own (the monorepo keeps one for all its packages), so `bun install` resolves the ranges in `package.json`.
|
|
62
|
+
|
|
59
63
|
### 3. Write a test
|
|
60
64
|
|
|
61
65
|
Tests live in the folder `srcDir` points to (`./src/tests` in `samurai.config.ts`) and end in `.spec.ts`. This repository has one as `src/tests/example.spec.ts`:
|
|
@@ -138,7 +142,8 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
|
|
|
138
142
|
| [Running tests and reports](https://github.com/degrootsam/samurai-framework/wiki/Running-tests-and-reports) | What a run does, `report.json`, page logs, timeouts |
|
|
139
143
|
| [Steps and the recorder](https://github.com/degrootsam/samurai-framework/wiki/Steps-and-the-recorder) | The step codec, the recording engine and the recorder toolbar |
|
|
140
144
|
| [Embedding the framework](https://github.com/degrootsam/samurai-framework/wiki/Embedding-the-framework) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
|
|
141
|
-
| [Contributing](
|
|
145
|
+
| [Contributing](CONTRIBUTING.md) | Issues and pull requests on this mirror |
|
|
146
|
+
| [Developer guide](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
|
|
142
147
|
| [Roadmap](https://github.com/degrootsam/samurai-framework/wiki/Roadmap) | What is built and what is still open |
|
|
143
148
|
|
|
144
149
|
The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/Home) lists every guide by topic.
|
|
@@ -149,6 +154,6 @@ The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/H
|
|
|
149
154
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
150
155
|
| `bun run dev` | `samurai` from the sources (`tsx src/index.ts`) |
|
|
151
156
|
| `bun run test` | Unit tests of the framework (no browser needed) |
|
|
152
|
-
| `bun run test
|
|
153
|
-
| `bun run test
|
|
157
|
+
| `bun run test-browser` | Browser tests of the framework (Firefox; `SAMURAI_TEST_BROWSER=chrome` or `bun run test-browser-chrome` for Chrome) |
|
|
158
|
+
| `bun run test-cli` | Tests of the `samurai` command (build first) |
|
|
154
159
|
| `bun run build` | Typechecks and compiles to `dist/` |
|
package/dist/browser/browser.js
CHANGED
|
@@ -67,12 +67,28 @@ export class Browser {
|
|
|
67
67
|
commandTimeout,
|
|
68
68
|
signal,
|
|
69
69
|
});
|
|
70
|
+
// The launchers stop listening for the abort once the session is open. Until this returns, nobody else
|
|
71
|
+
// holds the browser to close it, so an abort still has to stop it here. Once: a second stop would cut
|
|
72
|
+
// Chrome's short (chromedriver killed while it still quits Chrome)
|
|
73
|
+
let abandoned = false;
|
|
74
|
+
const abandon = () => {
|
|
75
|
+
if (abandoned)
|
|
76
|
+
return;
|
|
77
|
+
abandoned = true;
|
|
78
|
+
abandonLaunch(proc, connector);
|
|
79
|
+
};
|
|
80
|
+
signal?.addEventListener("abort", abandon, { once: true });
|
|
70
81
|
try {
|
|
71
|
-
|
|
82
|
+
const launched = await Browser.attach(proc, connector, browserName);
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
return launched;
|
|
72
85
|
}
|
|
73
86
|
catch (err) {
|
|
74
|
-
|
|
75
|
-
throw err;
|
|
87
|
+
abandon();
|
|
88
|
+
throw signal?.aborted ? signal.reason : err;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
signal?.removeEventListener("abort", abandon);
|
|
76
92
|
}
|
|
77
93
|
}
|
|
78
94
|
/** Sets up a browser whose BiDi session is open: downloads folder, the first page and what it tracks */
|
|
@@ -60,6 +60,8 @@ export declare class ChromeProcess extends EventEmitter implements BrowserProces
|
|
|
60
60
|
private deleteSession;
|
|
61
61
|
session: string | undefined;
|
|
62
62
|
private done;
|
|
63
|
+
/** The session is being deleted: chromedriver is killed once that is over, not before */
|
|
64
|
+
private quitting;
|
|
63
65
|
constructor(driver: Pick<ReturnType<typeof spawn>, "once" | "kill" | "exitCode" | "signalCode">, deleteSession?: (url: string) => Promise<unknown>);
|
|
64
66
|
get exitCode(): number | null;
|
|
65
67
|
get signalCode(): NodeJS.Signals | null;
|
|
@@ -139,6 +139,8 @@ export class ChromeProcess extends EventEmitter {
|
|
|
139
139
|
this.driver = driver;
|
|
140
140
|
this.deleteSession = deleteSession;
|
|
141
141
|
this.done = false;
|
|
142
|
+
/** The session is being deleted: chromedriver is killed once that is over, not before */
|
|
143
|
+
this.quitting = false;
|
|
142
144
|
driver.once("exit", () => this.ended());
|
|
143
145
|
}
|
|
144
146
|
get exitCode() {
|
|
@@ -159,7 +161,10 @@ export class ChromeProcess extends EventEmitter {
|
|
|
159
161
|
// Deleting the session makes chromedriver quit Chrome; killing chromedriver alone could leave it running
|
|
160
162
|
const session = this.session;
|
|
161
163
|
this.session = undefined;
|
|
164
|
+
if (this.quitting)
|
|
165
|
+
return true;
|
|
162
166
|
if (session) {
|
|
167
|
+
this.quitting = true;
|
|
163
168
|
this.deleteSession(session)
|
|
164
169
|
.catch(() => undefined)
|
|
165
170
|
.finally(() => this.driver.kill(signal));
|
package/dist/config/mask.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Logform } from "winston";
|
|
1
2
|
export declare const MASK = "\u2022\u2022\u2022\u2022";
|
|
2
3
|
/** Registers a secret value; every later maskText/maskDeep/log line hides it */
|
|
3
4
|
export declare function registerSecret(name: string, value: string): void;
|
|
@@ -7,5 +8,8 @@ export declare function isShortSecret(value: string): boolean;
|
|
|
7
8
|
export declare function maskText(text: string): string;
|
|
8
9
|
/** A copy of `value` with every string in it masked, following JSON.stringify semantics for objects */
|
|
9
10
|
export declare function maskDeep<T>(value: T): T;
|
|
10
|
-
/**
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Winston format that masks every string field of a log entry (message and metadata). Typed through winston's
|
|
13
|
+
* re-export: logform is winston's dependency, not ours, so an inferred type can't be named under an isolated install
|
|
14
|
+
*/
|
|
15
|
+
export declare const maskFormat: Logform.FormatWrap;
|
package/dist/config/mask.js
CHANGED
|
@@ -80,7 +80,10 @@ function walk(value, path) {
|
|
|
80
80
|
path.delete(value);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Winston format that masks every string field of a log entry (message and metadata). Typed through winston's
|
|
85
|
+
* re-export: logform is winston's dependency, not ours, so an inferred type can't be named under an isolated install
|
|
86
|
+
*/
|
|
84
87
|
export const maskFormat = format((info) => {
|
|
85
88
|
if (patterns.length === 0)
|
|
86
89
|
return info;
|
|
@@ -11,6 +11,12 @@ export interface TrackerOptions {
|
|
|
11
11
|
* Follows the network activity of one page (its frames included): which requests are in flight,
|
|
12
12
|
* and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
|
|
13
13
|
* so a redirect chain is one live request at a time.
|
|
14
|
+
*
|
|
15
|
+
* Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
|
|
16
|
+
* leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
|
|
17
|
+
* redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
|
|
18
|
+
* So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
|
|
19
|
+
* with its response or error is announced then.
|
|
14
20
|
*/
|
|
15
21
|
export declare class NetworkTracker {
|
|
16
22
|
private tree;
|
|
@@ -21,13 +27,16 @@ export declare class NetworkTracker {
|
|
|
21
27
|
private subscription;
|
|
22
28
|
/**
|
|
23
29
|
* Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
|
|
24
|
-
* hop's flight (`live` false) but keeps it, for the next
|
|
30
|
+
* hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
|
|
31
|
+
* hop's `redirectedFrom`.
|
|
25
32
|
*/
|
|
26
33
|
private hops;
|
|
27
34
|
/** Rejects a pending `waitForIdle`; each wait adds its own */
|
|
28
35
|
private waiters;
|
|
29
36
|
private disposed;
|
|
30
37
|
private constructor();
|
|
38
|
+
/** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
|
|
39
|
+
private get redirectsStartNextHop();
|
|
31
40
|
static start(connector: BiDiConnector, tree: ContextTree, contextId: string, options?: TrackerOptions): Promise<NetworkTracker>;
|
|
32
41
|
/** Requests currently in flight */
|
|
33
42
|
get inflight(): number;
|
|
@@ -52,6 +61,13 @@ export declare class NetworkTracker {
|
|
|
52
61
|
private owns;
|
|
53
62
|
private static key;
|
|
54
63
|
private onBeforeRequestSent;
|
|
64
|
+
/** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
|
|
65
|
+
private startHop;
|
|
66
|
+
/**
|
|
67
|
+
* The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
|
|
68
|
+
* when the hop it was redirected from is known.
|
|
69
|
+
*/
|
|
70
|
+
private hopOf;
|
|
55
71
|
private onResponseCompleted;
|
|
56
72
|
private onFetchError;
|
|
57
73
|
/**
|
|
@@ -12,6 +12,12 @@ const URLS_IN_MESSAGE = 5;
|
|
|
12
12
|
* Follows the network activity of one page (its frames included): which requests are in flight,
|
|
13
13
|
* and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
|
|
14
14
|
* so a redirect chain is one live request at a time.
|
|
15
|
+
*
|
|
16
|
+
* Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
|
|
17
|
+
* leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
|
|
18
|
+
* redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
|
|
19
|
+
* So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
|
|
20
|
+
* with its response or error is announced then.
|
|
15
21
|
*/
|
|
16
22
|
export class NetworkTracker {
|
|
17
23
|
constructor(tree, contextId, ignore, connector) {
|
|
@@ -22,7 +28,8 @@ export class NetworkTracker {
|
|
|
22
28
|
this.events = new EventEmitter();
|
|
23
29
|
/**
|
|
24
30
|
* Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
|
|
25
|
-
* hop's flight (`live` false) but keeps it, for the next
|
|
31
|
+
* hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
|
|
32
|
+
* hop's `redirectedFrom`.
|
|
26
33
|
*/
|
|
27
34
|
this.hops = new Map();
|
|
28
35
|
/** Rejects a pending `waitForIdle`; each wait adds its own */
|
|
@@ -31,30 +38,22 @@ export class NetworkTracker {
|
|
|
31
38
|
this.onBeforeRequestSent = (params) => {
|
|
32
39
|
if (!this.owns(params.context))
|
|
33
40
|
return;
|
|
34
|
-
|
|
35
|
-
const { redirectCount } = params;
|
|
36
|
-
const previousKey = `${params.request.request}:${redirectCount - 1}`;
|
|
37
|
-
const redirectedFrom = redirectCount > 0 ? (this.hops.get(previousKey)?.request ?? null) : null;
|
|
38
|
-
this.hops.delete(previousKey);
|
|
39
|
-
const request = toRequest(params.request, params.navigation, params.isBlocked, redirectedFrom);
|
|
40
|
-
if (this.ignore(request))
|
|
41
|
-
return;
|
|
42
|
-
this.hops.set(key, { request, context: params.context, live: true });
|
|
43
|
-
this.emit("request", request);
|
|
44
|
-
this.events.emit("change");
|
|
41
|
+
this.startHop(params);
|
|
45
42
|
};
|
|
46
43
|
this.onResponseCompleted = (params) => {
|
|
47
44
|
if (!this.owns(params.context))
|
|
48
45
|
return;
|
|
49
46
|
const key = NetworkTracker.key(params.request, params.redirectCount);
|
|
50
|
-
const hop = this.
|
|
51
|
-
if (!hop?.live)
|
|
47
|
+
const hop = this.hopOf(params);
|
|
48
|
+
if (!hop?.live || hop.answered)
|
|
52
49
|
return;
|
|
53
50
|
const { request } = hop;
|
|
54
51
|
const { response } = params;
|
|
55
52
|
// A redirect response keeps its request around: the next hop links back to it
|
|
56
53
|
if (response.status < 300 || response.status >= 400)
|
|
57
54
|
this.hops.delete(key);
|
|
55
|
+
else if (this.redirectsStartNextHop && hasLocation(response))
|
|
56
|
+
hop.answered = true;
|
|
58
57
|
else
|
|
59
58
|
hop.live = false;
|
|
60
59
|
this.emit("response", {
|
|
@@ -71,11 +70,13 @@ export class NetworkTracker {
|
|
|
71
70
|
if (!this.owns(params.context))
|
|
72
71
|
return;
|
|
73
72
|
const key = NetworkTracker.key(params.request, params.redirectCount);
|
|
74
|
-
const hop = this.
|
|
73
|
+
const hop = this.hopOf(params);
|
|
75
74
|
if (!hop?.live)
|
|
76
75
|
return;
|
|
77
76
|
this.hops.delete(key);
|
|
78
|
-
|
|
77
|
+
// A hop that has its response already did not fail
|
|
78
|
+
if (!hop.answered)
|
|
79
|
+
this.emit("requestfailed", Object.assign(hop.request, { errorText: params.errorText }));
|
|
79
80
|
this.events.emit("change");
|
|
80
81
|
};
|
|
81
82
|
/**
|
|
@@ -86,19 +87,24 @@ export class NetworkTracker {
|
|
|
86
87
|
if (!this.owns(params.context))
|
|
87
88
|
return;
|
|
88
89
|
let changed = false;
|
|
89
|
-
for (const [key, { request, context, live }] of [...this.hops]) {
|
|
90
|
+
for (const [key, { request, context, live, answered }] of [...this.hops]) {
|
|
90
91
|
if (!live || request.navigation === params.navigation)
|
|
91
92
|
continue;
|
|
92
93
|
if (!this.tree.isWithin(context, params.context))
|
|
93
94
|
continue;
|
|
94
95
|
this.hops.delete(key);
|
|
95
|
-
|
|
96
|
+
if (!answered)
|
|
97
|
+
this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
|
|
96
98
|
changed = true;
|
|
97
99
|
}
|
|
98
100
|
if (changed)
|
|
99
101
|
this.events.emit("change");
|
|
100
102
|
};
|
|
101
103
|
}
|
|
104
|
+
/** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
|
|
105
|
+
get redirectsStartNextHop() {
|
|
106
|
+
return this.connector.browserName === "chrome";
|
|
107
|
+
}
|
|
102
108
|
static async start(connector, tree, contextId, options = {}) {
|
|
103
109
|
const tracker = new NetworkTracker(tree, contextId, options.ignore ?? (() => false), connector);
|
|
104
110
|
// Listen first, then subscribe: nothing can arrive in between
|
|
@@ -189,6 +195,41 @@ export class NetworkTracker {
|
|
|
189
195
|
static key(data, redirectCount) {
|
|
190
196
|
return `${data.request}:${redirectCount}`;
|
|
191
197
|
}
|
|
198
|
+
/** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
|
|
199
|
+
startHop(params) {
|
|
200
|
+
const key = NetworkTracker.key(params.request, params.redirectCount);
|
|
201
|
+
const { redirectCount } = params;
|
|
202
|
+
const previousKey = `${params.request.request}:${redirectCount - 1}`;
|
|
203
|
+
const previous = redirectCount > 0 ? this.hops.get(previousKey) : undefined;
|
|
204
|
+
this.hops.delete(previousKey);
|
|
205
|
+
const request = toRequest(params.request, params.navigation, params.isBlocked, previous?.request ?? null);
|
|
206
|
+
if (this.ignore(request)) {
|
|
207
|
+
if (previous?.live)
|
|
208
|
+
this.events.emit("change");
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const hop = {
|
|
212
|
+
request,
|
|
213
|
+
context: params.context,
|
|
214
|
+
live: true,
|
|
215
|
+
answered: false,
|
|
216
|
+
};
|
|
217
|
+
this.hops.set(key, hop);
|
|
218
|
+
this.emit("request", request);
|
|
219
|
+
this.events.emit("change");
|
|
220
|
+
return hop;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
|
|
224
|
+
* when the hop it was redirected from is known.
|
|
225
|
+
*/
|
|
226
|
+
hopOf(params) {
|
|
227
|
+
const hop = this.hops.get(NetworkTracker.key(params.request, params.redirectCount));
|
|
228
|
+
if (hop || params.redirectCount === 0)
|
|
229
|
+
return hop;
|
|
230
|
+
const previousKey = `${params.request.request}:${params.redirectCount - 1}`;
|
|
231
|
+
return this.hops.has(previousKey) ? this.startHop(params) : undefined;
|
|
232
|
+
}
|
|
192
233
|
/** A listener that throws must not stop the tracker or the other listeners */
|
|
193
234
|
emit(event, ...args) {
|
|
194
235
|
for (const listener of this.events.listeners(event)) {
|
|
@@ -201,3 +242,7 @@ export class NetworkTracker {
|
|
|
201
242
|
}
|
|
202
243
|
}
|
|
203
244
|
}
|
|
245
|
+
/** Whether a 3xx response says where to go next: only then does the browser follow it */
|
|
246
|
+
function hasLocation(response) {
|
|
247
|
+
return response.headers.some((header) => header.name.toLowerCase() === "location");
|
|
248
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { PickInfo } from "../protocol.js";
|
|
2
2
|
/** Whether a text holds the value of one of the secrets */
|
|
3
3
|
export declare function holdsSecret(secrets: Map<string, string | undefined>): (text: string | undefined) => boolean;
|
|
4
|
+
/** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
|
|
5
|
+
export declare function maskSecrets(secrets: Map<string, string | undefined>): (text: string) => string;
|
|
4
6
|
/**
|
|
5
7
|
* A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
|
|
8
|
+
* the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
|
|
6
9
|
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
7
10
|
*/
|
|
8
11
|
export declare function withoutSecrets(info: PickInfo & {
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
+
import { MASK } from "../../../config/mask.js";
|
|
1
2
|
import { expectationFor } from "../../expectation.js";
|
|
2
3
|
/** Whether a text holds the value of one of the secrets */
|
|
3
4
|
export function holdsSecret(secrets) {
|
|
4
5
|
const values = [...secrets.values()].filter((value) => value !== undefined && value !== "");
|
|
5
6
|
return (text) => text !== undefined && values.some((value) => text.includes(value));
|
|
6
7
|
}
|
|
8
|
+
/** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
|
|
9
|
+
export function maskSecrets(secrets) {
|
|
10
|
+
const values = [...secrets.values()]
|
|
11
|
+
.filter((value) => value !== undefined && value !== "")
|
|
12
|
+
.sort((a, b) => b.length - a.length);
|
|
13
|
+
return (text) => values.reduce((masked, value) => masked.split(value).join(MASK), text);
|
|
14
|
+
}
|
|
7
15
|
/**
|
|
8
16
|
* A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
|
|
17
|
+
* the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
|
|
9
18
|
* and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
|
|
10
19
|
*/
|
|
11
20
|
export function withoutSecrets(info, secrets) {
|
|
@@ -24,7 +33,8 @@ export function withoutSecrets(info, secrets) {
|
|
|
24
33
|
return {
|
|
25
34
|
...rest,
|
|
26
35
|
text,
|
|
27
|
-
outputText:
|
|
36
|
+
outputText: maskSecrets(secrets)(info.outputText),
|
|
37
|
+
...(secret(info.outputText) && { showsSecret: true }),
|
|
28
38
|
attributes: info.attributes.filter((a) => !secret(a.value)),
|
|
29
39
|
className: secret(info.className ?? undefined) ? null : info.className,
|
|
30
40
|
css: secret(info.css.value) ? { ...info.css, value: "" } : info.css,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import type { ActionReply } from "../protocol.js";
|
|
1
|
+
import type { ActionReply, OutputPreview } from "../protocol.js";
|
|
2
2
|
import type { ActionContext, Of } from "../types.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
4
|
+
* What Save as output would save from an element that shows a secret, worked out here because the page only has
|
|
5
|
+
* its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
|
|
6
|
+
* as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
|
|
7
|
+
*/
|
|
8
|
+
export declare function outputPreview(ctx: ActionContext, message: Of<"output-preview">): OutputPreview;
|
|
9
|
+
/**
|
|
10
|
+
* Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
|
|
11
|
+
* name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
|
|
12
|
+
* or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
|
|
13
|
+
* secret is saved too: the panel warns that the output may hold it.
|
|
8
14
|
*/
|
|
9
15
|
export declare function saveOutputAction(ctx: ActionContext, message: Of<"save-output">): Promise<ActionReply>;
|
|
@@ -1,38 +1,65 @@
|
|
|
1
|
-
import { matchesText } from "../../../assert/text-match.js";
|
|
2
|
-
import { holdsSecret } from "./pick.js";
|
|
1
|
+
import { captured, matchesText } from "../../../assert/text-match.js";
|
|
2
|
+
import { holdsSecret, maskSecrets } from "./pick.js";
|
|
3
3
|
import { fail, nameProblem, PAUSED } from "./reply.js";
|
|
4
|
+
const PICK_AGAIN = "Pick the element again";
|
|
5
|
+
/** A pattern as typed, compiled the way the step writes it; undefined when there is none, an error when it isn't valid */
|
|
6
|
+
function compile(pattern) {
|
|
7
|
+
const source = String(pattern ?? "").trim();
|
|
8
|
+
if (source === "")
|
|
9
|
+
return undefined;
|
|
10
|
+
try {
|
|
11
|
+
return new RegExp(source);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { error: "pattern is not a valid regular expression" };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
4
17
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
18
|
+
* What Save as output would save from an element that shows a secret, worked out here because the page only has
|
|
19
|
+
* its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
|
|
20
|
+
* as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
|
|
21
|
+
*/
|
|
22
|
+
export function outputPreview(ctx, message) {
|
|
23
|
+
const pattern = String(message.pattern ?? "");
|
|
24
|
+
const answer = { preview: true, pattern };
|
|
25
|
+
const pick = ctx.pick();
|
|
26
|
+
if (!pick || pick.id !== message.id)
|
|
27
|
+
return { ...answer, ok: false, error: PICK_AGAIN };
|
|
28
|
+
const compiled = compile(pattern);
|
|
29
|
+
if (compiled && !(compiled instanceof RegExp))
|
|
30
|
+
return { ...answer, ok: false, error: "Not a valid pattern" };
|
|
31
|
+
if (compiled && !matchesText(pick.text, compiled, "exact"))
|
|
32
|
+
return { ...answer, ok: false, error: "No match" };
|
|
33
|
+
const value = compiled ? captured(compiled, pick.text) : pick.text;
|
|
34
|
+
return {
|
|
35
|
+
...answer,
|
|
36
|
+
ok: true,
|
|
37
|
+
value: maskSecrets(ctx.secrets)(value),
|
|
38
|
+
secret: holdsSecret(ctx.secrets)(value),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
|
|
43
|
+
* name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
|
|
44
|
+
* or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
|
|
45
|
+
* secret is saved too: the panel warns that the output may hold it.
|
|
9
46
|
*/
|
|
10
47
|
export async function saveOutputAction(ctx, message) {
|
|
11
48
|
if (ctx.isPaused())
|
|
12
49
|
return fail(PAUSED);
|
|
13
50
|
const pick = ctx.pick();
|
|
14
51
|
if (!pick || pick.id !== message.id)
|
|
15
|
-
return fail(
|
|
16
|
-
// The step would write the secret into the run's results
|
|
17
|
-
if (holdsSecret(ctx.secrets)(pick.text))
|
|
18
|
-
return fail("this element shows a secret; it can't be an output");
|
|
52
|
+
return fail(PICK_AGAIN);
|
|
19
53
|
const name = String(message.name ?? "").trim();
|
|
20
54
|
const problem = nameProblem(name);
|
|
21
55
|
if (problem)
|
|
22
56
|
return fail(problem, "name");
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
catch {
|
|
30
|
-
return fail("pattern is not a valid regular expression");
|
|
31
|
-
}
|
|
32
|
-
if (!matchesText(pick.text, pattern, "exact"))
|
|
33
|
-
return fail("pattern doesn't match the element's text");
|
|
34
|
-
}
|
|
35
|
-
else if (pick.text === "")
|
|
57
|
+
const pattern = compile(message.pattern);
|
|
58
|
+
if (pattern && !(pattern instanceof RegExp))
|
|
59
|
+
return fail(pattern.error);
|
|
60
|
+
if (pattern && !matchesText(pick.text, pattern, "exact"))
|
|
61
|
+
return fail("pattern doesn't match the element's text");
|
|
62
|
+
if (!pattern && pick.text === "")
|
|
36
63
|
return fail("the element has no text");
|
|
37
64
|
ctx.setPick(undefined);
|
|
38
65
|
ctx.deps.observe({
|
|
@@ -4,7 +4,7 @@ import { assertAction } from "./actions/assert.js";
|
|
|
4
4
|
import { assertPageAction, pageInfoOf } from "./actions/assert-page.js";
|
|
5
5
|
import { addInsertableAction, insertAction } from "./actions/insert.js";
|
|
6
6
|
import { withoutSecrets } from "./actions/pick.js";
|
|
7
|
-
import { saveOutputAction } from "./actions/save-output.js";
|
|
7
|
+
import { outputPreview, saveOutputAction } from "./actions/save-output.js";
|
|
8
8
|
import { fail, PAUSED, UNIDENTIFIABLE } from "./actions/reply.js";
|
|
9
9
|
import { insertablesOf } from "./insertables.js";
|
|
10
10
|
import { LEFT, PageScope } from "./page-scope.js";
|
|
@@ -148,6 +148,8 @@ export class ToolbarController {
|
|
|
148
148
|
return this.replying(message, () => addInsertableAction(this.actions, message));
|
|
149
149
|
case "save-output":
|
|
150
150
|
return this.replying(message, () => saveOutputAction(this.actions, message));
|
|
151
|
+
case "output-preview":
|
|
152
|
+
return this.reply(message.id, outputPreview(this.actions, message));
|
|
151
153
|
case "wait":
|
|
152
154
|
if (this.paused)
|
|
153
155
|
return;
|
|
@@ -118,6 +118,12 @@ export type ToolbarMessage = {
|
|
|
118
118
|
name: string;
|
|
119
119
|
pattern?: string;
|
|
120
120
|
}
|
|
121
|
+
/** The pattern of Save as output for an element that shows a secret, as typed: answered with an `OutputPreview` */
|
|
122
|
+
| {
|
|
123
|
+
action: "output-preview";
|
|
124
|
+
id: number;
|
|
125
|
+
pattern: string;
|
|
126
|
+
}
|
|
121
127
|
/** The Page panel opened: answered with a `PageInfo` */
|
|
122
128
|
| {
|
|
123
129
|
action: "page-info";
|
|
@@ -148,6 +154,8 @@ export type PickInfo = {
|
|
|
148
154
|
text: string;
|
|
149
155
|
/** The whole text as `outputs.save` reads it (`textContent`, trimmed): what Save as output captures from */
|
|
150
156
|
outputText: string;
|
|
157
|
+
/** `outputText` held a secret, which is masked in it: Save as output asks the recorder for its preview */
|
|
158
|
+
showsSecret?: true;
|
|
151
159
|
value?: string;
|
|
152
160
|
attributes: {
|
|
153
161
|
name: string;
|
|
@@ -186,6 +194,21 @@ export type PageInfo = {
|
|
|
186
194
|
ok: false;
|
|
187
195
|
error: string;
|
|
188
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* The answer to `output-preview`: what `outputs.save` would keep from the element's real text with `pattern`,
|
|
199
|
+
* every secret masked; `secret` when the value held one. `pattern` is the one asked about, so a late answer is dropped.
|
|
200
|
+
*/
|
|
201
|
+
export type OutputPreview = {
|
|
202
|
+
preview: true;
|
|
203
|
+
pattern: string;
|
|
204
|
+
} & ({
|
|
205
|
+
ok: true;
|
|
206
|
+
value: string;
|
|
207
|
+
secret: boolean;
|
|
208
|
+
} | {
|
|
209
|
+
ok: false;
|
|
210
|
+
error: string;
|
|
211
|
+
});
|
|
189
212
|
/** The answer to `insert`, `add-insertable`, `assert`, `assert-page` and `save-output` */
|
|
190
213
|
export type ActionReply = {
|
|
191
214
|
ok: true;
|
|
@@ -16,7 +16,10 @@ export const FIELDS_JS = String.raw ` const press = (action, detail) => {
|
|
|
16
16
|
if (ui.output[key] === value) return;
|
|
17
17
|
ui.output[key] = value;
|
|
18
18
|
if (key === "name") ui.output.nameError = null;
|
|
19
|
-
else
|
|
19
|
+
else {
|
|
20
|
+
ui.output.error = null;
|
|
21
|
+
if (ui.output.info && ui.output.info.ok && ui.output.info.showsSecret) askPreview(ui.output);
|
|
22
|
+
}
|
|
20
23
|
render();
|
|
21
24
|
}
|
|
22
25
|
else if (field === "attribute" && ui.assert) {
|
|
@@ -63,7 +63,7 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
|
|
|
63
63
|
const r = target.getBoundingClientRect();
|
|
64
64
|
const rect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
|
65
65
|
if (ui.pickFor === "output") {
|
|
66
|
-
ui.output = { id, target, candidates, info: null, name: "", pattern: "", nameError: null, error: null, rect };
|
|
66
|
+
ui.output = { id, target, candidates, info: null, name: "", pattern: "", preview: null, nameError: null, error: null, rect };
|
|
67
67
|
} else {
|
|
68
68
|
ui.assert = {
|
|
69
69
|
id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0,
|
|
@@ -27,6 +27,13 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
|
|
|
27
27
|
render();
|
|
28
28
|
};
|
|
29
29
|
const reply = (id, result) => {
|
|
30
|
+
// A Save as output preview, for the pattern still typed (a late one is dropped); it doesn't end a pending action
|
|
31
|
+
if (result && result.preview) {
|
|
32
|
+
const o = ui.panel === "output" && ui.output;
|
|
33
|
+
if (!o || o.id !== id || o.pattern.trim() !== result.pattern) return;
|
|
34
|
+
o.preview = result;
|
|
35
|
+
return render();
|
|
36
|
+
}
|
|
30
37
|
if (!ui.pending || id !== ui.pending || !result) return;
|
|
31
38
|
ui.busy = false;
|
|
32
39
|
const ok = result.ok === true;
|
|
@@ -44,6 +51,7 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
|
|
|
44
51
|
} else if (ui.panel === "output" && ui.output && !ui.output.info) {
|
|
45
52
|
ui.output.info = result;
|
|
46
53
|
if (ok) ui.focus = "outputName";
|
|
54
|
+
if (ok && result.showsSecret) askPreview(ui.output);
|
|
47
55
|
} else if (ui.panel === "page" && ui.page && !ui.page.info) {
|
|
48
56
|
ui.page.info = result;
|
|
49
57
|
if (ok) {
|
|
@@ -82,17 +82,28 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
|
|
|
82
82
|
if (!match) return { error: "No match" };
|
|
83
83
|
return { value: match[1] !== undefined ? match[1] : match[0] };
|
|
84
84
|
};
|
|
85
|
+
// An element that shows a secret reaches the page with it masked: the recorder works out its preview from the real text
|
|
86
|
+
const askPreview = (o) => {
|
|
87
|
+
o.preview = null;
|
|
88
|
+
post({ action: "output-preview", id: o.id, pattern: o.pattern.trim() });
|
|
89
|
+
};
|
|
90
|
+
const outputCapture = (o) => {
|
|
91
|
+
if (!o.info.showsSecret) return captureOf(o.info.outputText, o.pattern);
|
|
92
|
+
if (!o.preview) return { value: "…" };
|
|
93
|
+
return o.preview.ok ? { value: o.preview.value, secret: o.preview.secret } : { error: o.preview.error };
|
|
94
|
+
};
|
|
85
95
|
const outputPanel = () => {
|
|
86
96
|
const o = ui.output;
|
|
87
97
|
const info = o.info;
|
|
88
98
|
const head = h("div", { class: "h" }, "Save as output", info && info.ok && h("code", { title: info.label }, info.label));
|
|
89
99
|
if (!info) return [head, h("div", { class: "msg" }, "…")];
|
|
90
100
|
if (!info.ok) return [head, h("div", { class: "err" }, info.error)];
|
|
91
|
-
const capture =
|
|
101
|
+
const capture = outputCapture(o);
|
|
92
102
|
return [
|
|
93
103
|
head,
|
|
104
|
+
info.showsSecret && h("div", { class: "warn out-secret" }, "This element shows a secret. The saved output may contain it; use a pattern that captures only the non-secret part."),
|
|
94
105
|
h("div", { class: "lbl" }, "Text"),
|
|
95
|
-
h("div", { class: "msg out-text" }, info.text),
|
|
106
|
+
h("div", { class: "msg out-text" }, info.showsSecret ? info.outputText : info.text),
|
|
96
107
|
h("div", { class: "lbl" }, "Name"),
|
|
97
108
|
h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputName", value: o.name, "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
|
|
98
109
|
o.nameError && h("div", { class: "field-error" }, o.nameError),
|
|
@@ -100,6 +111,7 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
|
|
|
100
111
|
h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputPattern", value: o.pattern, placeholder: "Order #(\\d+)", "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
|
|
101
112
|
h("div", { class: "lbl" }, "Saves"),
|
|
102
113
|
capture.error ? h("div", { class: "warn out-capture" }, capture.error) : h("code", { class: "out-capture" }, capture.value),
|
|
114
|
+
capture.secret && h("div", { class: "err out-leak" }, "The output will contain a secret: reports mask it, but whatever reads the output gets the real value."),
|
|
103
115
|
o.error && h("div", { class: "err" }, o.error),
|
|
104
116
|
h("div", { class: "foot" }, h("button", { class: "add", "data-action": "output-save", disabled: ui.busy || !o.name.trim() }, "Save")),
|
|
105
117
|
];
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
|
|
10
10
|
* - `update(state)`: the recorder's state, after every change and every page load.
|
|
11
11
|
* - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
|
|
12
|
-
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`)
|
|
12
|
+
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
|
|
13
|
+
* `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
|
|
13
14
|
* - `root`: the shadow root, for tests.
|
|
14
15
|
* - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
|
|
15
16
|
* `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
|
|
@@ -24,7 +24,8 @@ import { ATTACH_JS } from "./source/attach.js";
|
|
|
24
24
|
* `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
|
|
25
25
|
* - `update(state)`: the recorder's state, after every change and every page load.
|
|
26
26
|
* - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
|
|
27
|
-
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`)
|
|
27
|
+
* `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
|
|
28
|
+
* `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
|
|
28
29
|
* - `root`: the shadow root, for tests.
|
|
29
30
|
* - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
|
|
30
31
|
* `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ElementHandle } from "../../script/element-handle.js";
|
|
2
2
|
import type { LocatorCall, LocatorSpec } from "../../steps/model.js";
|
|
3
3
|
import type { Observation } from "../normalise.js";
|
|
4
|
-
import type { ActionReply, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
|
|
4
|
+
import type { ActionReply, OutputPreview, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
|
|
5
5
|
/** What `inspect` found for a picked element: the reply, and the locator behind each Count choice */
|
|
6
6
|
export interface Inspection {
|
|
7
7
|
info: PickInfo & {
|
|
@@ -14,7 +14,7 @@ export interface Inspection {
|
|
|
14
14
|
export interface ControllerDeps {
|
|
15
15
|
/** Sends the state to every loaded top-level document's toolbar */
|
|
16
16
|
push(state: ToolbarState): void;
|
|
17
|
-
reply(id: number, result: PickInfo | PageInfo | ActionReply): void;
|
|
17
|
+
reply(id: number, result: PickInfo | PageInfo | ActionReply | OutputPreview): void;
|
|
18
18
|
currentUrl(): Promise<string>;
|
|
19
19
|
/** The recorded page's URL and title */
|
|
20
20
|
pageInfo(): Promise<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itmetsam/samurai-framework",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
|
|
5
5
|
"main": "dist/api.js",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,15 @@
|
|
|
57
57
|
"scripts": {
|
|
58
58
|
"dev": "tsx src/index.ts",
|
|
59
59
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
|
|
60
|
+
"build-watch": "node scripts/copy-types.mjs && tsc -w --preserveWatchOutput -p tsconfig.build.json",
|
|
60
61
|
"prepack": "npm run build",
|
|
62
|
+
"typecheck": "tsc --noEmit --noUnusedLocals",
|
|
61
63
|
"test": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-timeout=10000 \"src/**/*.test.ts\"",
|
|
62
|
-
"test
|
|
63
|
-
"test
|
|
64
|
-
"test
|
|
64
|
+
"test-browser": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 --test-timeout=60000 $(node scripts/browser-shards.mjs)",
|
|
65
|
+
"test-browser-chrome": "SAMURAI_TEST_BROWSER=chrome bun run test-browser",
|
|
66
|
+
"test-cli": "tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 \"src/**/*.cli-test.ts\"",
|
|
67
|
+
"knip": "knip --no-config-hints",
|
|
68
|
+
"madge": "madge --circular src"
|
|
65
69
|
},
|
|
66
70
|
"keywords": [
|
|
67
71
|
"testing",
|
|
@@ -77,6 +81,8 @@
|
|
|
77
81
|
"devDependencies": {
|
|
78
82
|
"@types/node": "^25.6.0",
|
|
79
83
|
"esbuild": "^0.28.2",
|
|
84
|
+
"knip": "^6.42.1",
|
|
85
|
+
"madge": "^8.0.0",
|
|
80
86
|
"tsx": "^4.23.15"
|
|
81
87
|
},
|
|
82
88
|
"dependencies": {
|
|
@@ -113,5 +119,39 @@
|
|
|
113
119
|
},
|
|
114
120
|
"engines": {
|
|
115
121
|
"node": ">=24"
|
|
122
|
+
},
|
|
123
|
+
"nx": {
|
|
124
|
+
"name": "framework",
|
|
125
|
+
"targets": {
|
|
126
|
+
"test": {
|
|
127
|
+
"dependsOn": [
|
|
128
|
+
"build"
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"test-cli": {
|
|
132
|
+
"dependsOn": [
|
|
133
|
+
"build"
|
|
134
|
+
],
|
|
135
|
+
"cache": true,
|
|
136
|
+
"parallelism": false
|
|
137
|
+
},
|
|
138
|
+
"test-browser": {
|
|
139
|
+
"cache": true,
|
|
140
|
+
"parallelism": false,
|
|
141
|
+
"inputs": [
|
|
142
|
+
"default",
|
|
143
|
+
{
|
|
144
|
+
"env": "SAMURAI_TEST_BROWSER"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"env": "SAMURAI_TEST_SHARD"
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
"build-watch": {
|
|
152
|
+
"continuous": true,
|
|
153
|
+
"cache": false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
116
156
|
}
|
|
117
157
|
}
|