@itmetsam/samurai-framework 0.7.1 → 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.
Files changed (51) hide show
  1. package/README.md +25 -6
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/file-chooser.d.ts +4 -3
  5. package/dist/browser/file-chooser.js +9 -8
  6. package/dist/browser/generated-files.d.ts +30 -0
  7. package/dist/browser/generated-files.js +116 -0
  8. package/dist/browser/page.d.ts +19 -0
  9. package/dist/browser/page.js +30 -0
  10. package/dist/cli/main.d.ts +1 -1
  11. package/dist/cli/main.js +20 -5
  12. package/dist/locator/locator.d.ts +17 -5
  13. package/dist/locator/locator.js +56 -8
  14. package/dist/project/files-store.d.ts +28 -0
  15. package/dist/project/files-store.js +106 -0
  16. package/dist/project/index.d.ts +1 -0
  17. package/dist/project/index.js +1 -0
  18. package/dist/recorder/apply.js +8 -6
  19. package/dist/recorder/files-capture.d.ts +26 -0
  20. package/dist/recorder/files-capture.js +92 -0
  21. package/dist/recorder/files-reader.d.ts +7 -0
  22. package/dist/recorder/files-reader.js +25 -0
  23. package/dist/recorder/index.d.ts +1 -1
  24. package/dist/recorder/index.js +1 -1
  25. package/dist/recorder/normalise.d.ts +26 -2
  26. package/dist/recorder/normalise.js +48 -1
  27. package/dist/recorder/recorder.d.ts +38 -0
  28. package/dist/recorder/recorder.js +160 -1
  29. package/dist/recorder/session.d.ts +14 -1
  30. package/dist/recorder/session.js +32 -6
  31. package/dist/recorder/toolbar/controller.d.ts +9 -0
  32. package/dist/recorder/toolbar/controller.js +22 -0
  33. package/dist/recorder/toolbar/protocol.d.ts +10 -0
  34. package/dist/recorder/toolbar/source.d.ts +1 -1
  35. package/dist/recorder/toolbar/source.js +35 -2
  36. package/dist/recorder/toolbar/styles.d.ts +1 -1
  37. package/dist/recorder/toolbar/styles.js +31 -0
  38. package/dist/runner/project.js +2 -0
  39. package/dist/steps/edit.js +38 -3
  40. package/dist/steps/emit.d.ts +3 -1
  41. package/dist/steps/emit.js +19 -0
  42. package/dist/steps/files.d.ts +8 -0
  43. package/dist/steps/files.js +33 -0
  44. package/dist/steps/index.d.ts +3 -2
  45. package/dist/steps/index.js +2 -1
  46. package/dist/steps/model.d.ts +28 -0
  47. package/dist/steps/parse.js +79 -0
  48. package/dist/types/config.d.ts +7 -2
  49. package/package.json +1 -1
  50. package/dist/browser/file-paths.d.ts +0 -4
  51. 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;
@@ -50,11 +53,21 @@ export interface RecordResult {
50
53
  secrets: string[];
51
54
  /** Those of `secrets` that have no value in the recording's environment yet, read when the recording ended */
52
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;
53
66
  }
54
67
  /** What the toolbar shows of the project: its environment, variables, secrets and the test's screenshots */
55
68
  export declare function toolbarOptions(run: Pick<PreparedRun, "settings" | "secrets">, source: string, dir: string, config: Pick<SamuraiTestConfig, "environments">, testIndex: number): Promise<ControllerOptions>;
56
69
  /** The secrets a recording needs set: those its steps read, then those added from the panel, once each */
57
- export declare function resultSecrets(events: readonly Pick<RecorderEvent, "step">[], added: Iterable<string>): string[];
70
+ export declare function resultSecrets(events: readonly RecorderEvent[], added: Iterable<string>): string[];
58
71
  /** The index of the test `selector` names (see `RecordSpecOptions.test`) */
59
72
  export declare function pickTest(tests: readonly {
60
73
  name: string;
@@ -4,10 +4,11 @@ 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
6
  import { loadSecrets } from "../config/secrets.js";
7
- import { isGitIgnored } from "../project/index.js";
7
+ import { filesFolder, isFolderIgnored, isGitIgnored, } from "../project/index.js";
8
8
  import { withProject } from "../runner/project.js";
9
9
  import { parseSpec } from "../steps/parse.js";
10
10
  import { applyRecorderEvent } from "./apply.js";
11
+ import { missingFileText, } from "./recorder.js";
11
12
  const NEW_SPEC_HEADER = `import { expect, test } from "@itmetsam/samurai-framework";\n\n`;
12
13
  function addTest(source, title) {
13
14
  const header = source.trim() === ""
@@ -49,8 +50,10 @@ export async function toolbarOptions(run, source, dir, config, testIndex) {
49
50
  export function resultSecrets(events, added) {
50
51
  return [
51
52
  ...new Set([
52
- ...events.flatMap(({ step }) => step.kind === "fill" && step.value.kind === "secret"
53
- ? [step.value.name]
53
+ ...events.flatMap((event) => event.op !== "remove" &&
54
+ event.step.kind === "fill" &&
55
+ event.step.value.kind === "secret"
56
+ ? [event.step.value.name]
54
57
  : []),
55
58
  ...added,
56
59
  ]),
@@ -118,6 +121,10 @@ export async function recordSpec(options) {
118
121
  port: options.port ?? 9223,
119
122
  headless: options.headless ?? settings.headless,
120
123
  }, options.signal);
124
+ const loaded = await loadConfig();
125
+ const folder = filesFolder(projectDir(), loaded.filesDir);
126
+ const hadFolder = existsSync(folder.absolute);
127
+ let recorder;
121
128
  const events = [];
122
129
  const addedSecrets = new Set();
123
130
  let stopRequested;
@@ -128,17 +135,22 @@ export async function recordSpec(options) {
128
135
  await page.goto(options.url);
129
136
  else if (settings.baseURL !== undefined)
130
137
  await page.navigateTo(settings.baseURL);
131
- const recorder = await page.record({
138
+ recorder = await page.record({
132
139
  at,
133
140
  initialGoto: at === 0 && start !== undefined,
134
141
  ...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
135
142
  toolbar: {
136
- ...(await toolbarOptions(run, source, projectDir(), await loadConfig(), testIndex)),
143
+ ...(await toolbarOptions(run, source, projectDir(), loaded, testIndex)),
137
144
  projectDir: projectDir(),
138
145
  ...(options.onStatus && { onStatus: options.onStatus }),
139
146
  onSecretAdded: (name) => addedSecrets.add(name),
140
147
  onStop: () => stopRequested(),
141
148
  },
149
+ files: {
150
+ projectDir: projectDir(),
151
+ ...(loaded.filesDir !== undefined && { filesDir: loaded.filesDir }),
152
+ onMissing: (file) => options.onMissingFile?.(file, missingFileText(file, folder.relative)),
153
+ },
142
154
  onEvent: (event) => {
143
155
  source = applyRecorderEvent(source, testIndex, event);
144
156
  writeFileSync(file, source);
@@ -169,6 +181,20 @@ export async function recordSpec(options) {
169
181
  // Read again: the toolbar may have written new ones to the .env file
170
182
  const set = loadSecrets(settings.environment);
171
183
  const unsetSecrets = secrets.filter((name) => !set.has(name));
172
- return { file, test: test.name, events, secrets, unsetSecrets };
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
+ };
173
199
  });
174
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
  `;
@@ -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;