@itmetsam/samurai-framework 0.7.0 → 0.8.0
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 +25 -6
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/file-chooser.d.ts +4 -3
- package/dist/browser/file-chooser.js +9 -8
- package/dist/browser/generated-files.d.ts +30 -0
- package/dist/browser/generated-files.js +116 -0
- package/dist/browser/page.d.ts +19 -0
- package/dist/browser/page.js +30 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +22 -7
- package/dist/locator/locator.d.ts +17 -5
- package/dist/locator/locator.js +56 -8
- package/dist/project/files-store.d.ts +28 -0
- package/dist/project/files-store.js +106 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/recorder/apply.js +8 -6
- package/dist/recorder/files-capture.d.ts +26 -0
- package/dist/recorder/files-capture.js +92 -0
- package/dist/recorder/files-reader.d.ts +7 -0
- package/dist/recorder/files-reader.js +25 -0
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/normalise.d.ts +26 -2
- package/dist/recorder/normalise.js +48 -1
- package/dist/recorder/recorder.d.ts +38 -0
- package/dist/recorder/recorder.js +160 -1
- package/dist/recorder/session.d.ts +16 -1
- package/dist/recorder/session.js +36 -6
- package/dist/recorder/toolbar/controller.d.ts +9 -0
- package/dist/recorder/toolbar/controller.js +22 -0
- package/dist/recorder/toolbar/protocol.d.ts +10 -0
- package/dist/recorder/toolbar/source.d.ts +1 -1
- package/dist/recorder/toolbar/source.js +35 -2
- package/dist/recorder/toolbar/styles.d.ts +1 -1
- package/dist/recorder/toolbar/styles.js +31 -0
- package/dist/runner/project.js +2 -0
- package/dist/steps/edit.d.ts +3 -2
- package/dist/steps/edit.js +64 -5
- package/dist/steps/emit.d.ts +3 -1
- package/dist/steps/emit.js +19 -0
- package/dist/steps/files.d.ts +8 -0
- package/dist/steps/files.js +33 -0
- package/dist/steps/index.d.ts +3 -2
- package/dist/steps/index.js +2 -1
- package/dist/steps/model.d.ts +38 -0
- package/dist/steps/parse.js +102 -0
- package/dist/types/config.d.ts +7 -2
- package/package.json +1 -1
- package/dist/browser/file-paths.d.ts +0 -4
- package/dist/browser/file-paths.js +0 -18
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import logger from "../logger/index.js";
|
|
3
6
|
import { addPreload, runInLoadedContexts } from "../script/preload.js";
|
|
4
7
|
import { callFunction } from "../script/call-function.js";
|
|
5
8
|
import { ChannelArg, fromRemoteValue } from "../script/serialize.js";
|
|
6
9
|
import { ElementHandle } from "../script/element-handle.js";
|
|
7
10
|
import { locatorFromSpec } from "../steps/build.js";
|
|
8
|
-
import { addVariable, setSecret } from "../project/index.js";
|
|
11
|
+
import { addVariable, cleanFileName, filesFolder, RECORD_LIMIT, setSecret, storeFile, } from "../project/index.js";
|
|
12
|
+
import { humanSize } from "../steps/files.js";
|
|
9
13
|
import { CAPTURE_MUTE, CAPTURE_OFF, CAPTURE_SOURCE, CAPTURE_UNMUTE, } from "./capture.js";
|
|
14
|
+
import { FILES_SANDBOX, filesCaptureSource } from "./files-capture.js";
|
|
15
|
+
import { disposeFilesCapture, readPageFile, releasePageFile, } from "./files-reader.js";
|
|
10
16
|
import { Normaliser, } from "./normalise.js";
|
|
11
17
|
import { rankLocator } from "./ranking.js";
|
|
12
18
|
import { ToolbarController, } from "./toolbar/controller.js";
|
|
@@ -36,6 +42,13 @@ export class Recorder {
|
|
|
36
42
|
this.queue = { tail: Promise.resolve() };
|
|
37
43
|
this.known = new Map();
|
|
38
44
|
this.stopped = false;
|
|
45
|
+
/** Set when stopping begins: files messages after it are not taken */
|
|
46
|
+
this.ending = false;
|
|
47
|
+
/** The files capture script's channel, handed only to its sandbox */
|
|
48
|
+
this.filesChannel = `samurai-files-${randomUUID()}`;
|
|
49
|
+
this.filesRealms = new Set();
|
|
50
|
+
this.stored = new Set();
|
|
51
|
+
this.missing = [];
|
|
39
52
|
/** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
|
|
40
53
|
this.toolbarRealms = new Set();
|
|
41
54
|
/** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
|
|
@@ -56,6 +69,23 @@ export class Recorder {
|
|
|
56
69
|
this.enqueue(async () => this.handle(message));
|
|
57
70
|
return;
|
|
58
71
|
}
|
|
72
|
+
if (params.channel === this.filesChannel) {
|
|
73
|
+
if (this.ending)
|
|
74
|
+
return;
|
|
75
|
+
const realm = params.source.realm ?? "";
|
|
76
|
+
const data = params.data;
|
|
77
|
+
// In order with the page's messages: a picker follows the click that opened it
|
|
78
|
+
this.enqueue(async () => {
|
|
79
|
+
if (!this.filesRealms.has(realm))
|
|
80
|
+
await this.listFilesRealms();
|
|
81
|
+
if (!this.filesRealms.has(realm)) {
|
|
82
|
+
logger.warn("Recorder ignored a files message from outside the files sandbox");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await this.fromFiles(fromRemoteValue(data));
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
59
89
|
if (params.channel !== this.toolbarChannel || !this.controller)
|
|
60
90
|
return;
|
|
61
91
|
const realm = params.source.realm ?? "";
|
|
@@ -96,6 +126,14 @@ export class Recorder {
|
|
|
96
126
|
});
|
|
97
127
|
};
|
|
98
128
|
}
|
|
129
|
+
/** Files stored or reused in the files folder by this recording, project-relative */
|
|
130
|
+
get storedFiles() {
|
|
131
|
+
return [...this.stored];
|
|
132
|
+
}
|
|
133
|
+
/** Files whose steps point at a path that wasn't written */
|
|
134
|
+
get missingFiles() {
|
|
135
|
+
return [...this.missing];
|
|
136
|
+
}
|
|
99
137
|
static async start(target, options) {
|
|
100
138
|
const at = options.at ?? 0;
|
|
101
139
|
const normaliser = new Normaliser({
|
|
@@ -108,6 +146,15 @@ export class Recorder {
|
|
|
108
146
|
await recorder.begin(options.initialGoto ?? true);
|
|
109
147
|
return recorder;
|
|
110
148
|
}
|
|
149
|
+
async listFilesRealms() {
|
|
150
|
+
const { realms } = await this.connector.send("script.getRealms", {
|
|
151
|
+
context: this.context,
|
|
152
|
+
type: "window",
|
|
153
|
+
});
|
|
154
|
+
this.filesRealms = new Set(realms
|
|
155
|
+
.filter((info) => info.type === "window" && info.sandbox === FILES_SANDBOX)
|
|
156
|
+
.map((info) => info.realm));
|
|
157
|
+
}
|
|
111
158
|
async listToolbarRealms() {
|
|
112
159
|
const { realms } = await this.connector.send("script.getRealms", {
|
|
113
160
|
context: this.context,
|
|
@@ -157,6 +204,17 @@ export class Recorder {
|
|
|
157
204
|
arguments: [new ChannelArg(this.channel)],
|
|
158
205
|
onRunError: "log",
|
|
159
206
|
});
|
|
207
|
+
if (this.options.files) {
|
|
208
|
+
this.filesPreload = await addPreload(this.connector, {
|
|
209
|
+
source: filesCaptureSource({
|
|
210
|
+
trustSynthetic: this.options.files.trustSynthetic === true,
|
|
211
|
+
}),
|
|
212
|
+
sandbox: FILES_SANDBOX,
|
|
213
|
+
contexts: [this.context],
|
|
214
|
+
arguments: [new ChannelArg(this.filesChannel)],
|
|
215
|
+
onRunError: "log",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
160
218
|
if (this.controller) {
|
|
161
219
|
this.toolbarPreload = await addPreload(this.connector, {
|
|
162
220
|
source: TOOLBAR_SOURCE,
|
|
@@ -225,6 +283,8 @@ export class Recorder {
|
|
|
225
283
|
this.options.onEvent(event);
|
|
226
284
|
if (event.op === "insert")
|
|
227
285
|
this.controller?.stepAdded(event.step);
|
|
286
|
+
else if (event.op === "remove")
|
|
287
|
+
this.controller?.stepRemoved();
|
|
228
288
|
}
|
|
229
289
|
}
|
|
230
290
|
async locatorFor(message) {
|
|
@@ -269,6 +329,93 @@ export class Recorder {
|
|
|
269
329
|
};
|
|
270
330
|
this.emit(this.normaliser.observe(observation, Date.now()));
|
|
271
331
|
}
|
|
332
|
+
async fromFiles(message) {
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
if (message.type !== "files") {
|
|
335
|
+
if (!this.controller?.paused)
|
|
336
|
+
this.emit(this.normaliser.observe(message, now));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const release = (from) => Promise.allSettled(message.files
|
|
340
|
+
.slice(from)
|
|
341
|
+
.map((file) => releasePageFile(this.connector, this.context, file.id)));
|
|
342
|
+
if (this.controller?.paused)
|
|
343
|
+
return void (await release(0));
|
|
344
|
+
// storePageFile releases its own file; whatever it never reached is released here, on any exit
|
|
345
|
+
let started = 0;
|
|
346
|
+
try {
|
|
347
|
+
const locator = await this.locatorFor(message);
|
|
348
|
+
if (!locator) {
|
|
349
|
+
logger.warn("Recorder found no locator that matches only the element the files were %s; the upload was skipped", message.via === "drop" ? "dropped on" : "set on");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const files = [];
|
|
353
|
+
for (const file of message.files) {
|
|
354
|
+
started++;
|
|
355
|
+
files.push({ path: await this.storePageFile(file) });
|
|
356
|
+
}
|
|
357
|
+
this.emit(this.normaliser.observe({ type: "files", via: message.via, locator, files }, now));
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
await release(started);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** Saves one kept file to the files folder; returns the path its step gets */
|
|
364
|
+
async storePageFile(file) {
|
|
365
|
+
const recording = this.options.files;
|
|
366
|
+
const folder = filesFolder(recording.projectDir, recording.filesDir);
|
|
367
|
+
const notSaved = (reason) => {
|
|
368
|
+
const missing = {
|
|
369
|
+
path: folder.relative
|
|
370
|
+
? `${folder.relative}/${cleanFileName(file.name)}`
|
|
371
|
+
: cleanFileName(file.name),
|
|
372
|
+
name: file.name,
|
|
373
|
+
size: file.size,
|
|
374
|
+
reason,
|
|
375
|
+
};
|
|
376
|
+
this.missing.push(missing);
|
|
377
|
+
recording.onMissing?.(missing);
|
|
378
|
+
this.controller?.warn(missingFileText(missing, folder.relative));
|
|
379
|
+
return missing.path;
|
|
380
|
+
};
|
|
381
|
+
if (file.size > RECORD_LIMIT) {
|
|
382
|
+
await releasePageFile(this.connector, this.context, file.id).catch(() => undefined);
|
|
383
|
+
return notSaved("too-big");
|
|
384
|
+
}
|
|
385
|
+
this.controller?.saving(file.name);
|
|
386
|
+
let temp;
|
|
387
|
+
try {
|
|
388
|
+
temp = await mkdtemp(path.join(tmpdir(), "samurai-upload-"));
|
|
389
|
+
const content = path.join(temp, "content");
|
|
390
|
+
const out = await open(content, "w");
|
|
391
|
+
try {
|
|
392
|
+
await readPageFile(this.connector, this.context, file.id, file.size, (bytes) => out.write(bytes));
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
await out.close();
|
|
396
|
+
}
|
|
397
|
+
const stored = await storeFile({
|
|
398
|
+
projectDir: recording.projectDir,
|
|
399
|
+
...(recording.filesDir !== undefined && {
|
|
400
|
+
filesDir: recording.filesDir,
|
|
401
|
+
}),
|
|
402
|
+
name: file.name,
|
|
403
|
+
content,
|
|
404
|
+
});
|
|
405
|
+
this.stored.add(stored.path);
|
|
406
|
+
return stored.path;
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
logger.warn("Recorder could not save %s", file.name, { err });
|
|
410
|
+
return notSaved("unreadable");
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
this.controller?.saving(undefined);
|
|
414
|
+
if (temp)
|
|
415
|
+
await rm(temp, { recursive: true, force: true }).catch(() => undefined);
|
|
416
|
+
await releasePageFile(this.connector, this.context, file.id).catch(() => undefined);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
272
419
|
async release() {
|
|
273
420
|
this.connector.offEvent("script.message", this.onMessage);
|
|
274
421
|
this.connector.offEvent("browsingContext.navigationStarted", this.onNavigation);
|
|
@@ -277,6 +424,7 @@ export class Recorder {
|
|
|
277
424
|
this.subscription?.unsubscribe(),
|
|
278
425
|
this.preload?.dispose(),
|
|
279
426
|
this.toolbarPreload?.dispose(),
|
|
427
|
+
this.filesPreload?.dispose(),
|
|
280
428
|
]);
|
|
281
429
|
}
|
|
282
430
|
/**
|
|
@@ -305,6 +453,7 @@ export class Recorder {
|
|
|
305
453
|
async end() {
|
|
306
454
|
if (this.stopped)
|
|
307
455
|
return;
|
|
456
|
+
this.ending = true;
|
|
308
457
|
this.controller?.dispose();
|
|
309
458
|
await runInLoadedContexts(this.connector, {
|
|
310
459
|
source: CAPTURE_OFF,
|
|
@@ -324,7 +473,17 @@ export class Recorder {
|
|
|
324
473
|
}).catch(() => undefined);
|
|
325
474
|
}
|
|
326
475
|
await this.queue.tail;
|
|
476
|
+
// Only now: started and queued transfers need the kept Files
|
|
477
|
+
if (this.filesPreload)
|
|
478
|
+
await disposeFilesCapture(this.connector, this.context).catch(() => undefined);
|
|
327
479
|
this.stopped = true;
|
|
328
480
|
await this.release();
|
|
329
481
|
}
|
|
330
482
|
}
|
|
483
|
+
/** The warning for a file that isn't saved, as the toolbar and the CLI show it */
|
|
484
|
+
export function missingFileText(file, folder) {
|
|
485
|
+
const where = folder === "" ? "the project folder" : `${folder}/`;
|
|
486
|
+
return file.reason === "too-big"
|
|
487
|
+
? `${file.name} is ${humanSize(file.size)}. Files over 50 MB aren't saved: add it to ${where} yourself, or use a generated file.`
|
|
488
|
+
: `${file.name} couldn't be read, so it isn't saved: add it to ${where} yourself, or use a generated file.`;
|
|
489
|
+
}
|
|
@@ -5,6 +5,7 @@ import { type ProjectOptions } from "../runner/project.js";
|
|
|
5
5
|
import type { SamuraiTestConfig } from "../types/config.js";
|
|
6
6
|
import type { ControllerOptions } from "./toolbar/controller.js";
|
|
7
7
|
import type { RecorderEvent } from "./normalise.js";
|
|
8
|
+
import { type MissingFile } from "./recorder.js";
|
|
8
9
|
export interface RecordSpecOptions extends ProjectOptions {
|
|
9
10
|
/** The spec file, absolute or relative to the project folder. Created when `create` is given and it doesn't exist */
|
|
10
11
|
file: string;
|
|
@@ -35,6 +36,8 @@ export interface RecordSpecOptions extends ProjectOptions {
|
|
|
35
36
|
state: "recording" | "paused";
|
|
36
37
|
toolbar: boolean;
|
|
37
38
|
}) => void;
|
|
39
|
+
/** A picked or dropped file wasn't saved (over 50 MB, or unreadable); `text` is the warning to show */
|
|
40
|
+
onMissingFile?: (file: MissingFile, text: string) => void;
|
|
38
41
|
/** Called once the browser is open and recording */
|
|
39
42
|
onStarted?: (context: {
|
|
40
43
|
page: Page;
|
|
@@ -48,11 +51,23 @@ export interface RecordResult {
|
|
|
48
51
|
events: RecorderEvent[];
|
|
49
52
|
/** Names the recorded steps read with `secrets.<NAME>`: they must be set before the test can run */
|
|
50
53
|
secrets: string[];
|
|
54
|
+
/** Those of `secrets` that have no value in the recording's environment yet, read when the recording ended */
|
|
55
|
+
unsetSecrets: string[];
|
|
56
|
+
/** Files stored in (or reused from) the files folder, project-relative */
|
|
57
|
+
files: string[];
|
|
58
|
+
/** Files the steps point at that weren't saved: they must be added before the test can run */
|
|
59
|
+
missingFiles: MissingFile[];
|
|
60
|
+
/** The files folder is git-ignored: CI won't have the files */
|
|
61
|
+
filesIgnored: boolean;
|
|
62
|
+
/** The files folder, relative to the project with forward slashes ("" for the project root) */
|
|
63
|
+
filesFolder: string;
|
|
64
|
+
/** The files folder was created by this recording */
|
|
65
|
+
firstFiles: boolean;
|
|
51
66
|
}
|
|
52
67
|
/** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
|
|
53
68
|
export declare function toolbarOptions(run: Pick<PreparedRun, "settings" | "secrets">, source: string, dir: string, config: Pick<SamuraiTestConfig, "environments">, testIndex: number): Promise<ControllerOptions>;
|
|
54
69
|
/** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
|
|
55
|
-
export declare function resultSecrets(events: readonly
|
|
70
|
+
export declare function resultSecrets(events: readonly RecorderEvent[], added: Iterable<string>): string[];
|
|
56
71
|
/** The index of the test `selector` names (see `RecordSpecOptions.test`) */
|
|
57
72
|
export declare function pickTest(tests: readonly {
|
|
58
73
|
name: string;
|
package/dist/recorder/session.js
CHANGED
|
@@ -3,10 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import { Browser } from "../browser/browser.js";
|
|
4
4
|
import { ensureWarm } from "../browser/warm-up.js";
|
|
5
5
|
import { loadConfig, projectDir } from "../config/config.js";
|
|
6
|
-
import {
|
|
6
|
+
import { loadSecrets } from "../config/secrets.js";
|
|
7
|
+
import { filesFolder, isFolderIgnored, isGitIgnored, } from "../project/index.js";
|
|
7
8
|
import { withProject } from "../runner/project.js";
|
|
8
9
|
import { parseSpec } from "../steps/parse.js";
|
|
9
10
|
import { applyRecorderEvent } from "./apply.js";
|
|
11
|
+
import { missingFileText, } from "./recorder.js";
|
|
10
12
|
const NEW_SPEC_HEADER = `import { expect, test } from "@itmetsam/samurai-framework";\n\n`;
|
|
11
13
|
function addTest(source, title) {
|
|
12
14
|
const header = source.trim() === ""
|
|
@@ -48,8 +50,10 @@ export async function toolbarOptions(run, source, dir, config, testIndex) {
|
|
|
48
50
|
export function resultSecrets(events, added) {
|
|
49
51
|
return [
|
|
50
52
|
...new Set([
|
|
51
|
-
...events.flatMap((
|
|
52
|
-
|
|
53
|
+
...events.flatMap((event) => event.op !== "remove" &&
|
|
54
|
+
event.step.kind === "fill" &&
|
|
55
|
+
event.step.value.kind === "secret"
|
|
56
|
+
? [event.step.value.name]
|
|
53
57
|
: []),
|
|
54
58
|
...added,
|
|
55
59
|
]),
|
|
@@ -117,6 +121,10 @@ export async function recordSpec(options) {
|
|
|
117
121
|
port: options.port ?? 9223,
|
|
118
122
|
headless: options.headless ?? settings.headless,
|
|
119
123
|
}, options.signal);
|
|
124
|
+
const loaded = await loadConfig();
|
|
125
|
+
const folder = filesFolder(projectDir(), loaded.filesDir);
|
|
126
|
+
const hadFolder = existsSync(folder.absolute);
|
|
127
|
+
let recorder;
|
|
120
128
|
const events = [];
|
|
121
129
|
const addedSecrets = new Set();
|
|
122
130
|
let stopRequested;
|
|
@@ -127,17 +135,22 @@ export async function recordSpec(options) {
|
|
|
127
135
|
await page.goto(options.url);
|
|
128
136
|
else if (settings.baseURL !== undefined)
|
|
129
137
|
await page.navigateTo(settings.baseURL);
|
|
130
|
-
|
|
138
|
+
recorder = await page.record({
|
|
131
139
|
at,
|
|
132
140
|
initialGoto: at === 0 && start !== undefined,
|
|
133
141
|
...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
|
|
134
142
|
toolbar: {
|
|
135
|
-
...(await toolbarOptions(run, source, projectDir(),
|
|
143
|
+
...(await toolbarOptions(run, source, projectDir(), loaded, testIndex)),
|
|
136
144
|
projectDir: projectDir(),
|
|
137
145
|
...(options.onStatus && { onStatus: options.onStatus }),
|
|
138
146
|
onSecretAdded: (name) => addedSecrets.add(name),
|
|
139
147
|
onStop: () => stopRequested(),
|
|
140
148
|
},
|
|
149
|
+
files: {
|
|
150
|
+
projectDir: projectDir(),
|
|
151
|
+
...(loaded.filesDir !== undefined && { filesDir: loaded.filesDir }),
|
|
152
|
+
onMissing: (file) => options.onMissingFile?.(file, missingFileText(file, folder.relative)),
|
|
153
|
+
},
|
|
141
154
|
onEvent: (event) => {
|
|
142
155
|
source = applyRecorderEvent(source, testIndex, event);
|
|
143
156
|
writeFileSync(file, source);
|
|
@@ -165,6 +178,23 @@ export async function recordSpec(options) {
|
|
|
165
178
|
await browser.close().catch(() => undefined);
|
|
166
179
|
}
|
|
167
180
|
const secrets = resultSecrets(events, addedSecrets);
|
|
168
|
-
|
|
181
|
+
// Read again: the toolbar may have written new ones to the .env file
|
|
182
|
+
const set = loadSecrets(settings.environment);
|
|
183
|
+
const unsetSecrets = secrets.filter((name) => !set.has(name));
|
|
184
|
+
const files = recorder?.storedFiles ?? [];
|
|
185
|
+
const missingFiles = recorder?.missingFiles ?? [];
|
|
186
|
+
return {
|
|
187
|
+
file,
|
|
188
|
+
test: test.name,
|
|
189
|
+
events,
|
|
190
|
+
secrets,
|
|
191
|
+
unsetSecrets,
|
|
192
|
+
files,
|
|
193
|
+
missingFiles,
|
|
194
|
+
filesFolder: folder.relative,
|
|
195
|
+
filesIgnored: files.length + missingFiles.length > 0 &&
|
|
196
|
+
(await isFolderIgnored(projectDir(), folder.relative)),
|
|
197
|
+
firstFiles: !hadFolder && files.length > 0,
|
|
198
|
+
};
|
|
169
199
|
});
|
|
170
200
|
}
|
|
@@ -66,6 +66,9 @@ export declare class ToolbarController {
|
|
|
66
66
|
private question;
|
|
67
67
|
private toast;
|
|
68
68
|
private toastId;
|
|
69
|
+
private savingName;
|
|
70
|
+
private warnings;
|
|
71
|
+
private warningId;
|
|
69
72
|
/** The picked element an `assert` with the same id refers to */
|
|
70
73
|
private pick;
|
|
71
74
|
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
@@ -88,6 +91,10 @@ export declare class ToolbarController {
|
|
|
88
91
|
private reply;
|
|
89
92
|
private reportStatus;
|
|
90
93
|
private setToolbar;
|
|
94
|
+
/** A picked or dropped file is being saved; undefined when done */
|
|
95
|
+
saving(name: string | undefined): void;
|
|
96
|
+
/** Shows a warning that stays until the tester dismisses it */
|
|
97
|
+
warn(text: string): void;
|
|
91
98
|
private showToast;
|
|
92
99
|
handle(message: Message): Promise<void>;
|
|
93
100
|
/** A top-level document finished loading: push state; start the 2 s ready watchdog */
|
|
@@ -99,6 +106,8 @@ export declare class ToolbarController {
|
|
|
99
106
|
navigating(): void;
|
|
100
107
|
/** A step was emitted (any source): count it */
|
|
101
108
|
stepAdded(step: Step): void;
|
|
109
|
+
/** A recorded step was taken out again (a cancelled file picker's click) */
|
|
110
|
+
stepRemoved(): void;
|
|
102
111
|
/** The recording ended: no more pushes, replies or status */
|
|
103
112
|
dispose(): void;
|
|
104
113
|
/**
|
|
@@ -31,6 +31,8 @@ export class ToolbarController {
|
|
|
31
31
|
this.position = null;
|
|
32
32
|
this.steps = 0;
|
|
33
33
|
this.toastId = 0;
|
|
34
|
+
this.warnings = [];
|
|
35
|
+
this.warningId = 0;
|
|
34
36
|
/** Ids of `assert`, `insert` and `add-insertable` being processed */
|
|
35
37
|
this.inFlight = new Set();
|
|
36
38
|
/** Ids `accept` let through whose `handle` hasn't started yet */
|
|
@@ -70,6 +72,8 @@ export class ToolbarController {
|
|
|
70
72
|
resumeQuestion: this.question !== undefined,
|
|
71
73
|
nextScreenshot: `screenshot-${this.screenshots + 1}`,
|
|
72
74
|
...(this.toast && { toast: this.toast }),
|
|
75
|
+
...(this.savingName !== undefined && { saving: this.savingName }),
|
|
76
|
+
warnings: this.warnings.map((w) => ({ ...w })),
|
|
73
77
|
};
|
|
74
78
|
}
|
|
75
79
|
push() {
|
|
@@ -90,6 +94,16 @@ export class ToolbarController {
|
|
|
90
94
|
this.toolbar = loaded;
|
|
91
95
|
this.reportStatus();
|
|
92
96
|
}
|
|
97
|
+
/** A picked or dropped file is being saved; undefined when done */
|
|
98
|
+
saving(name) {
|
|
99
|
+
this.savingName = name;
|
|
100
|
+
this.push();
|
|
101
|
+
}
|
|
102
|
+
/** Shows a warning that stays until the tester dismisses it */
|
|
103
|
+
warn(text) {
|
|
104
|
+
this.warnings.push({ id: ++this.warningId, text });
|
|
105
|
+
this.push();
|
|
106
|
+
}
|
|
93
107
|
showToast(text) {
|
|
94
108
|
this.toast = { id: ++this.toastId, text };
|
|
95
109
|
}
|
|
@@ -116,6 +130,9 @@ export class ToolbarController {
|
|
|
116
130
|
this.collapsed = message.collapsed === true;
|
|
117
131
|
this.pick = undefined;
|
|
118
132
|
return this.push();
|
|
133
|
+
case "dismiss":
|
|
134
|
+
this.warnings = this.warnings.filter((w) => w.id !== message.id);
|
|
135
|
+
return this.push();
|
|
119
136
|
case "pick":
|
|
120
137
|
return this.picked(message);
|
|
121
138
|
case "assert":
|
|
@@ -188,6 +205,11 @@ export class ToolbarController {
|
|
|
188
205
|
this.screenshots++;
|
|
189
206
|
this.push();
|
|
190
207
|
}
|
|
208
|
+
/** A recorded step was taken out again (a cancelled file picker's click) */
|
|
209
|
+
stepRemoved() {
|
|
210
|
+
this.steps = Math.max(0, this.steps - 1);
|
|
211
|
+
this.push();
|
|
212
|
+
}
|
|
191
213
|
/** The recording ended: no more pushes, replies or status */
|
|
192
214
|
dispose() {
|
|
193
215
|
this.disposed = true;
|
|
@@ -36,6 +36,13 @@ export interface ToolbarState {
|
|
|
36
36
|
id: number;
|
|
37
37
|
text: string;
|
|
38
38
|
};
|
|
39
|
+
/** The name of the file being saved to the project, while a transfer runs */
|
|
40
|
+
saving?: string;
|
|
41
|
+
/** Shown until the tester dismisses each (a file that wasn't saved) */
|
|
42
|
+
warnings: {
|
|
43
|
+
id: number;
|
|
44
|
+
text: string;
|
|
45
|
+
}[];
|
|
39
46
|
}
|
|
40
47
|
/** Element-carrying messages send the element itself (it arrives as a handle) and its candidates */
|
|
41
48
|
export type ToolbarMessage = {
|
|
@@ -61,6 +68,9 @@ export type ToolbarMessage = {
|
|
|
61
68
|
} | {
|
|
62
69
|
action: "collapse";
|
|
63
70
|
collapsed: boolean;
|
|
71
|
+
} | {
|
|
72
|
+
action: "dismiss";
|
|
73
|
+
id: number;
|
|
64
74
|
} | {
|
|
65
75
|
action: "pick";
|
|
66
76
|
id: number;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* `matcher { matcher }`, `not`, `assert-add` (Assert panel);
|
|
19
19
|
* `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
|
|
20
20
|
* `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
|
|
21
|
-
* `wait-add`, `screenshot-add`; `escape` (the Esc key).
|
|
21
|
+
* `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
|
|
22
22
|
* Form inputs carry `data-field` (`expected`, `attribute`, `countIndex`, `filter`, `name`, `value`,
|
|
23
23
|
* `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
|
|
24
24
|
* - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
|
|
@@ -21,7 +21,7 @@ import { TOOLBAR_CSS } from "./styles.js";
|
|
|
21
21
|
* `matcher { matcher }`, `not`, `assert-add` (Assert panel);
|
|
22
22
|
* `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
|
|
23
23
|
* `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
|
|
24
|
-
* `wait-add`, `screenshot-add`; `escape` (the Esc key).
|
|
24
|
+
* `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
|
|
25
25
|
* Form inputs carry `data-field` (`expected`, `attribute`, `countIndex`, `filter`, `name`, `value`,
|
|
26
26
|
* `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
|
|
27
27
|
* - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
|
|
@@ -46,6 +46,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
46
46
|
const DEFAULTS = {
|
|
47
47
|
status: "recording", collapsed: false, position: null, steps: 0, environment: "", canAddVariables: false,
|
|
48
48
|
variables: [], secrets: [], secretsIgnored: true, resumeQuestion: false, nextScreenshot: "screenshot-1",
|
|
49
|
+
warnings: [],
|
|
49
50
|
};
|
|
50
51
|
const MATCHERS = [
|
|
51
52
|
["toBeVisible", "Visible"], ["toHaveText", "Has text"], ["toContainText", "Contains text"],
|
|
@@ -109,6 +110,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
109
110
|
const container = h("div", { class: "ui" });
|
|
110
111
|
const outline = h("div", { class: "outline", hidden: true });
|
|
111
112
|
const toast = h("div", { class: "toast ui", hidden: true });
|
|
113
|
+
const notices = h("div", { class: "notices ui" });
|
|
112
114
|
let adopted = false;
|
|
113
115
|
try {
|
|
114
116
|
const sheet = new CSSStyleSheet();
|
|
@@ -119,7 +121,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
119
121
|
adopted = false;
|
|
120
122
|
}
|
|
121
123
|
// Without constructed sheets: a <style> element, which a strict CSP blocks
|
|
122
|
-
root.append(...(adopted ? [] : [h("style", null, CSS)]), frame, outline, container, toast);
|
|
124
|
+
root.append(...(adopted ? [] : [h("style", null, CSS)]), frame, outline, container, toast, notices);
|
|
123
125
|
|
|
124
126
|
const viewport = () => ({ w: frame.clientWidth || innerWidth, h: frame.clientHeight || innerHeight });
|
|
125
127
|
|
|
@@ -366,6 +368,34 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
366
368
|
];
|
|
367
369
|
const PANELS = { assert: assertPanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
|
|
368
370
|
|
|
371
|
+
// Notice nodes live as long as their warning: rebuilding them would make screen readers announce each alert again
|
|
372
|
+
const noticeNodes = new Map();
|
|
373
|
+
let savingNode = null;
|
|
374
|
+
let savingText = null;
|
|
375
|
+
const renderNotices = () => {
|
|
376
|
+
const ids = new Set(state.warnings.map((w) => w.id));
|
|
377
|
+
for (const [id, node] of noticeNodes) {
|
|
378
|
+
if (ids.has(id)) continue;
|
|
379
|
+
node.remove();
|
|
380
|
+
noticeNodes.delete(id);
|
|
381
|
+
}
|
|
382
|
+
for (const w of state.warnings) {
|
|
383
|
+
if (noticeNodes.has(w.id)) continue;
|
|
384
|
+
const node = h("div", { class: "notice warning", "data-warning": String(w.id), role: "alert" },
|
|
385
|
+
h("span", null, w.text),
|
|
386
|
+
h("button", { class: "dismiss", "data-action": "dismiss", "data-id": String(w.id) }, "Dismiss"));
|
|
387
|
+
noticeNodes.set(w.id, node);
|
|
388
|
+
// Before the saving line, which stays last
|
|
389
|
+
notices.insertBefore(node, savingNode);
|
|
390
|
+
}
|
|
391
|
+
const saving = state.saving || null;
|
|
392
|
+
if (saving !== savingText) {
|
|
393
|
+
savingText = saving;
|
|
394
|
+
if (savingNode) savingNode.remove();
|
|
395
|
+
savingNode = saving ? h("div", { class: "notice saving" }, "Saving " + saving + "…") : null;
|
|
396
|
+
if (savingNode) notices.append(savingNode);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
369
399
|
const render = () => {
|
|
370
400
|
const active = root.activeElement;
|
|
371
401
|
const kept = active && active.type !== "radio" ? active.getAttribute("data-field") : null;
|
|
@@ -373,6 +403,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
373
403
|
try { selection = kept ? [active.selectionStart, active.selectionEnd] : null; } catch (err) { selection = null; }
|
|
374
404
|
const panel = ui.panel && !state.collapsed ? h("div", { class: ui.panel === "new" || ui.panel === "insert" ? "panel wide" : "panel" }, PANELS[ui.panel]()) : null;
|
|
375
405
|
container.replaceChildren(state.collapsed ? chipView() : railView(), ...(panel ? [panel] : []));
|
|
406
|
+
renderNotices();
|
|
376
407
|
placeAll();
|
|
377
408
|
const focus = ui.focus || kept;
|
|
378
409
|
ui.focus = null;
|
|
@@ -457,6 +488,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
457
488
|
|
|
458
489
|
// Every action is what a click on its control does; press() runs the same functions
|
|
459
490
|
const handlers = {
|
|
491
|
+
dismiss: (d) => post({ action: "dismiss", id: Number(d.id) }),
|
|
460
492
|
pause: () => { if (!paused()) post({ action: "pause" }); },
|
|
461
493
|
resume: () => { if (paused()) post({ action: "resume" }); },
|
|
462
494
|
stop: () => {
|
|
@@ -670,6 +702,7 @@ export const TOOLBAR_SOURCE = String.raw `(send) => {
|
|
|
670
702
|
kind: control.getAttribute("data-kind"),
|
|
671
703
|
name: control.getAttribute("data-name"),
|
|
672
704
|
matcher: control.getAttribute("data-matcher"),
|
|
705
|
+
id: control.getAttribute("data-id"),
|
|
673
706
|
});
|
|
674
707
|
});
|
|
675
708
|
root.addEventListener("pointerdown", (event) => {
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
* outline and the toast, as in the approved mockups. Everything inside is `position: fixed` against the
|
|
4
4
|
* viewport; the host itself takes no space.
|
|
5
5
|
*/
|
|
6
|
-
export declare const TOOLBAR_CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n.ui { font: 12px/1.3 system-ui, -apple-system, \"Segoe UI\", sans-serif; color: #222; color-scheme: light; }\nbutton, input, select { font: inherit; color: inherit; margin: 0; }\nbutton { appearance: none; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }\nbutton:disabled { cursor: default; }\ncode { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n\n.rail, .chip { position: fixed; z-index: 3; box-shadow: 0 6px 20px rgba(0,0,0,.28); background: #17181c; color: #e9eaee; user-select: none; }\n.rail { display: flex; flex-direction: column; gap: 2px; border-radius: 10px; padding: 5px; width: 112px; }\n.rail.wide { width: 150px; }\n.top { display: flex; align-items: center; justify-content: space-between; color: #6b6e78; padding: 0 2px 2px; }\n[data-grip] { cursor: grab; flex: 1; touch-action: none; }\n[data-grip]:active { cursor: grabbing; }\n.chev { color: #6b6e78; padding: 0 3px; border-radius: 4px; }\n.chev:hover { color: #e9eaee; }\n.st { display: flex; align-items: center; gap: 6px; padding: 4px 6px; font-weight: 600; }\n.dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,.25); flex: none; }\n.dot.pause { background: #f5b301; box-shadow: 0 0 0 3px rgba(245,179,1,.25); }\n.b { padding: 5px 7px; border-radius: 6px; display: flex; align-items: center; gap: 6px; width: 100%; }\n.b:hover:not(:disabled) { background: #2a2c33; }\n.b.on, .b.on:hover:not(:disabled) { background: #2f6fed; color: #fff; }\n.b:disabled { opacity: .38; }\n.sep { height: 1px; background: #34363d; margin: 2px 3px; }\n.stop { color: #ff8a8c; }\n.ico { width: 14px; text-align: center; flex: none; }\n.note { font-size: 10.5px; color: #c9cbd2; padding: 4px 6px 2px; line-height: 1.35; }\n.ask { display: flex; gap: 4px; padding: 2px 6px 4px; }\n.ask button { flex: 1; text-align: center; border-radius: 5px; padding: 3px 0; background: #2a2c33; }\n.ask button.yes { background: #2f6fed; color: #fff; font-weight: 600; }\n\n.chip { display: flex; align-items: center; gap: 7px; border-radius: 999px; padding: 6px 10px 6px 8px; font-weight: 600; }\n.chip [data-grip] { color: #6b6e78; flex: none; }\n.chip .chev { color: #9a9ca5; }\n\n.panel { position: fixed; z-index: 2; width: 230px; max-height: calc(100vh - 16px); overflow: auto; background: #fff; border: 1px solid #d9dce1; border-radius: 10px; box-shadow: 0 10px 28px rgba(0,0,0,.18); color: #222; }\n.panel.wide { width: 236px; }\n.h { padding: 8px 10px; border-bottom: 1px solid #eceef1; font-weight: 600; display: flex; justify-content: space-between; gap: 8px; }\n.h.stack { display: block; }\n.h code { font-size: 11px; color: #555; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.h small { display: block; font-weight: 400; color: #777; margin-top: 2px; }\n.msg { padding: 10px; color: #555; }\n.err { padding: 6px 10px; color: #c22b2e; }\n.opts { padding: 6px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }\n.o { border: 1px solid #e3e5e8; border-radius: 6px; padding: 5px 6px; }\n.o:hover:not(:disabled) { border-color: #b9c6e4; }\n.o.sel { border-color: #2f6fed; background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.o:disabled { opacity: .45; }\n.val { margin: 2px 8px 8px; display: flex; flex-direction: column; gap: 4px; }\n.in { width: 100%; border: 1px solid #cfd3d8; border-radius: 4px; padding: 5px 6px; background: #fff; outline: none; }\n.in:focus { border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47,111,237,.15); }\n.in.code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n.row { display: flex; justify-content: space-between; align-items: center; padding: 0 8px 8px; }\n.neg { color: #555; display: flex; align-items: center; gap: 4px; }\n.add { background: #2f6fed; color: #fff; border-radius: 6px; padding: 5px 12px; font-weight: 600; text-align: center; }\n.add:disabled { opacity: .5; }\n.cancel { padding: 5px 10px; color: #555; }\n.foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 10px; border-top: 1px solid #eceef1; }\n.foot.plain { border-top: 0; padding-top: 10px; }\n\n.search { display: block; margin: 6px 8px; width: calc(100% - 16px); }\n.grp { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: #888; padding: 6px 10px 2px; }\n.it { display: flex; justify-content: space-between; gap: 8px; padding: 5px 10px; width: 100%; }\n.it:hover, .it.sel { background: #eef3fe; }\n.it .v { color: #777; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.warn { color: #b26a00; }\n.empty { padding: 4px 10px; color: #777; }\n.act { padding: 6px 10px; color: #1d4fc4; font-weight: 600; width: 100%; }\n.act:hover { background: #eef3fe; }\n\n.seg { display: flex; margin: 8px 10px 0; border: 1px solid #cfd3d8; border-radius: 6px; overflow: hidden; }\n.seg button { flex: 1; text-align: center; padding: 4px; }\n.seg .sel { background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.lbl { font-size: 11px; color: #666; padding: 6px 10px 2px; }\n.f { padding: 0 10px; }\n.field-error { color: #c22b2e; font-size: 11px; padding: 3px 10px 0; }\n.hint { padding: 6px 10px 0; color: #666; font-size: 11px; line-height: 1.35; }\n.hint code { font-size: 10.5px; }\n\n.radio { padding: 6px 10px; display: flex; gap: 7px; align-items: center; }\n.radio input[type=radio] { accent-color: #2f6fed; margin: 0; }\n.sub { color: #777; font-size: 11px; padding: 0 10px 2px 30px; margin-top: -4px; }\n.num { width: 52px; border: 1px solid #cfd3d8; border-radius: 4px; padding: 3px 5px; }\n\n.outline { position: fixed; z-index: 1; pointer-events: none; outline: 2px solid #2f6fed; outline-offset: 2px; border-radius: 4px; background: rgba(47,111,237,.06); }\n.toast { position: fixed; z-index: 4; left: 50%; bottom: 14px; transform: translateX(-50%); background: #17181c; color: #e9eaee; border-radius: 8px; padding: 6px 12px; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; transition: opacity .3s; }\n.toast.gone { opacity: 0; }\n.frame { position: fixed; inset: 0; pointer-events: none; visibility: hidden; }\n";
|
|
6
|
+
export declare const TOOLBAR_CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n.ui { font: 12px/1.3 system-ui, -apple-system, \"Segoe UI\", sans-serif; color: #222; color-scheme: light; }\nbutton, input, select { font: inherit; color: inherit; margin: 0; }\nbutton { appearance: none; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }\nbutton:disabled { cursor: default; }\ncode { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n\n.rail, .chip { position: fixed; z-index: 3; box-shadow: 0 6px 20px rgba(0,0,0,.28); background: #17181c; color: #e9eaee; user-select: none; }\n.rail { display: flex; flex-direction: column; gap: 2px; border-radius: 10px; padding: 5px; width: 112px; }\n.rail.wide { width: 150px; }\n.top { display: flex; align-items: center; justify-content: space-between; color: #6b6e78; padding: 0 2px 2px; }\n[data-grip] { cursor: grab; flex: 1; touch-action: none; }\n[data-grip]:active { cursor: grabbing; }\n.chev { color: #6b6e78; padding: 0 3px; border-radius: 4px; }\n.chev:hover { color: #e9eaee; }\n.st { display: flex; align-items: center; gap: 6px; padding: 4px 6px; font-weight: 600; }\n.dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,.25); flex: none; }\n.dot.pause { background: #f5b301; box-shadow: 0 0 0 3px rgba(245,179,1,.25); }\n.b { padding: 5px 7px; border-radius: 6px; display: flex; align-items: center; gap: 6px; width: 100%; }\n.b:hover:not(:disabled) { background: #2a2c33; }\n.b.on, .b.on:hover:not(:disabled) { background: #2f6fed; color: #fff; }\n.b:disabled { opacity: .38; }\n.sep { height: 1px; background: #34363d; margin: 2px 3px; }\n.stop { color: #ff8a8c; }\n.ico { width: 14px; text-align: center; flex: none; }\n.note { font-size: 10.5px; color: #c9cbd2; padding: 4px 6px 2px; line-height: 1.35; }\n.ask { display: flex; gap: 4px; padding: 2px 6px 4px; }\n.ask button { flex: 1; text-align: center; border-radius: 5px; padding: 3px 0; background: #2a2c33; }\n.ask button.yes { background: #2f6fed; color: #fff; font-weight: 600; }\n\n.chip { display: flex; align-items: center; gap: 7px; border-radius: 999px; padding: 6px 10px 6px 8px; font-weight: 600; }\n.chip [data-grip] { color: #6b6e78; flex: none; }\n.chip .chev { color: #9a9ca5; }\n\n.panel { position: fixed; z-index: 2; width: 230px; max-height: calc(100vh - 16px); overflow: auto; background: #fff; border: 1px solid #d9dce1; border-radius: 10px; box-shadow: 0 10px 28px rgba(0,0,0,.18); color: #222; }\n.panel.wide { width: 236px; }\n.h { padding: 8px 10px; border-bottom: 1px solid #eceef1; font-weight: 600; display: flex; justify-content: space-between; gap: 8px; }\n.h.stack { display: block; }\n.h code { font-size: 11px; color: #555; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.h small { display: block; font-weight: 400; color: #777; margin-top: 2px; }\n.msg { padding: 10px; color: #555; }\n.err { padding: 6px 10px; color: #c22b2e; }\n.opts { padding: 6px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }\n.o { border: 1px solid #e3e5e8; border-radius: 6px; padding: 5px 6px; }\n.o:hover:not(:disabled) { border-color: #b9c6e4; }\n.o.sel { border-color: #2f6fed; background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.o:disabled { opacity: .45; }\n.val { margin: 2px 8px 8px; display: flex; flex-direction: column; gap: 4px; }\n.in { width: 100%; border: 1px solid #cfd3d8; border-radius: 4px; padding: 5px 6px; background: #fff; outline: none; }\n.in:focus { border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47,111,237,.15); }\n.in.code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }\n.row { display: flex; justify-content: space-between; align-items: center; padding: 0 8px 8px; }\n.neg { color: #555; display: flex; align-items: center; gap: 4px; }\n.add { background: #2f6fed; color: #fff; border-radius: 6px; padding: 5px 12px; font-weight: 600; text-align: center; }\n.add:disabled { opacity: .5; }\n.cancel { padding: 5px 10px; color: #555; }\n.foot { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 10px; border-top: 1px solid #eceef1; }\n.foot.plain { border-top: 0; padding-top: 10px; }\n\n.search { display: block; margin: 6px 8px; width: calc(100% - 16px); }\n.grp { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: #888; padding: 6px 10px 2px; }\n.it { display: flex; justify-content: space-between; gap: 8px; padding: 5px 10px; width: 100%; }\n.it:hover, .it.sel { background: #eef3fe; }\n.it .v { color: #777; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.warn { color: #b26a00; }\n.empty { padding: 4px 10px; color: #777; }\n.act { padding: 6px 10px; color: #1d4fc4; font-weight: 600; width: 100%; }\n.act:hover { background: #eef3fe; }\n\n.seg { display: flex; margin: 8px 10px 0; border: 1px solid #cfd3d8; border-radius: 6px; overflow: hidden; }\n.seg button { flex: 1; text-align: center; padding: 4px; }\n.seg .sel { background: #eef3fe; color: #1d4fc4; font-weight: 600; }\n.lbl { font-size: 11px; color: #666; padding: 6px 10px 2px; }\n.f { padding: 0 10px; }\n.field-error { color: #c22b2e; font-size: 11px; padding: 3px 10px 0; }\n.hint { padding: 6px 10px 0; color: #666; font-size: 11px; line-height: 1.35; }\n.hint code { font-size: 10.5px; }\n\n.radio { padding: 6px 10px; display: flex; gap: 7px; align-items: center; }\n.radio input[type=radio] { accent-color: #2f6fed; margin: 0; }\n.sub { color: #777; font-size: 11px; padding: 0 10px 2px 30px; margin-top: -4px; }\n.num { width: 52px; border: 1px solid #cfd3d8; border-radius: 4px; padding: 3px 5px; }\n\n.outline { position: fixed; z-index: 1; pointer-events: none; outline: 2px solid #2f6fed; outline-offset: 2px; border-radius: 4px; background: rgba(47,111,237,.06); }\n.toast { position: fixed; z-index: 4; left: 50%; bottom: 14px; transform: translateX(-50%); background: #17181c; color: #e9eaee; border-radius: 8px; padding: 6px 12px; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; transition: opacity .3s; }\n.toast.gone { opacity: 0; }\n.notices {\n position: fixed;\n z-index: 4;\n left: 50%;\n bottom: 52px;\n transform: translateX(-50%);\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-width: min(520px, calc(100vw - 32px));\n}\n.notice {\n display: flex;\n gap: 10px;\n align-items: center;\n background: #17181c;\n color: #e9eaee;\n border-radius: 8px;\n padding: 8px 12px;\n box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);\n}\n.notice.warning {\n background: #3b2a06;\n color: #ffe7b0;\n border: 1px solid #8a6514;\n}\n.notice .dismiss {\n margin-left: auto;\n text-decoration: underline;\n white-space: nowrap;\n}\n.frame { position: fixed; inset: 0; pointer-events: none; visibility: hidden; }\n";
|
|
@@ -91,5 +91,36 @@ code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
91
91
|
.outline { position: fixed; z-index: 1; pointer-events: none; outline: 2px solid #2f6fed; outline-offset: 2px; border-radius: 4px; background: rgba(47,111,237,.06); }
|
|
92
92
|
.toast { position: fixed; z-index: 4; left: 50%; bottom: 14px; transform: translateX(-50%); background: #17181c; color: #e9eaee; border-radius: 8px; padding: 6px 12px; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; transition: opacity .3s; }
|
|
93
93
|
.toast.gone { opacity: 0; }
|
|
94
|
+
.notices {
|
|
95
|
+
position: fixed;
|
|
96
|
+
z-index: 4;
|
|
97
|
+
left: 50%;
|
|
98
|
+
bottom: 52px;
|
|
99
|
+
transform: translateX(-50%);
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 6px;
|
|
103
|
+
max-width: min(520px, calc(100vw - 32px));
|
|
104
|
+
}
|
|
105
|
+
.notice {
|
|
106
|
+
display: flex;
|
|
107
|
+
gap: 10px;
|
|
108
|
+
align-items: center;
|
|
109
|
+
background: #17181c;
|
|
110
|
+
color: #e9eaee;
|
|
111
|
+
border-radius: 8px;
|
|
112
|
+
padding: 8px 12px;
|
|
113
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
|
|
114
|
+
}
|
|
115
|
+
.notice.warning {
|
|
116
|
+
background: #3b2a06;
|
|
117
|
+
color: #ffe7b0;
|
|
118
|
+
border: 1px solid #8a6514;
|
|
119
|
+
}
|
|
120
|
+
.notice .dismiss {
|
|
121
|
+
margin-left: auto;
|
|
122
|
+
text-decoration: underline;
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
}
|
|
94
125
|
.frame { position: fixed; inset: 0; pointer-events: none; visibility: hidden; }
|
|
95
126
|
`;
|
package/dist/runner/project.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { removeGeneratedFiles } from "../browser/generated-files.js";
|
|
1
2
|
import { loadConfig, projectDir, setActiveProject } from "../config/config.js";
|
|
2
3
|
import { resetSecrets } from "../config/mask.js";
|
|
3
4
|
import { setRunSettings } from "../config/run-settings.js";
|
|
@@ -27,6 +28,7 @@ export async function withProject(options, work) {
|
|
|
27
28
|
return await work(run);
|
|
28
29
|
}
|
|
29
30
|
finally {
|
|
31
|
+
await removeGeneratedFiles().catch(() => undefined);
|
|
30
32
|
setRunSettings(undefined);
|
|
31
33
|
setActiveProject(undefined);
|
|
32
34
|
active = false;
|