@arraypress/extension-kit 1.10.0 → 1.11.1

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 (69) hide show
  1. package/README.md +103 -21
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/vendor.d.ts +29 -8
  9. package/src/build/vendor.js +136 -40
  10. package/src/content/highlight.d.ts +15 -6
  11. package/src/content/highlight.js +42 -17
  12. package/src/content/overlay.d.ts +19 -4
  13. package/src/content/overlay.js +44 -6
  14. package/src/content/slices.d.ts +3 -1
  15. package/src/content/slices.js +29 -14
  16. package/src/content/watch.d.ts +4 -2
  17. package/src/content/watch.js +17 -3
  18. package/src/dom/focus.d.ts +11 -0
  19. package/src/dom/focus.js +15 -0
  20. package/src/frame/window.d.ts +1 -1
  21. package/src/frame/window.js +8 -7
  22. package/src/index.d.ts +10 -3
  23. package/src/index.js +8 -3
  24. package/src/panel/show-on-page.d.ts +82 -0
  25. package/src/panel/show-on-page.js +83 -0
  26. package/src/scripting/call.d.ts +29 -9
  27. package/src/scripting/call.js +35 -11
  28. package/src/scripting/context-menus.d.ts +45 -0
  29. package/src/scripting/context-menus.js +53 -0
  30. package/src/scripting/registration.d.ts +2 -1
  31. package/src/scripting/registration.js +2 -1
  32. package/src/scripting/tab.d.ts +17 -5
  33. package/src/scripting/tab.js +15 -7
  34. package/src/settings/saver.js +16 -6
  35. package/src/settings/storage.d.ts +33 -2
  36. package/src/settings/storage.js +73 -3
  37. package/src/settings/store.d.ts +17 -0
  38. package/src/settings/store.js +87 -20
  39. package/src/sites/sites.d.ts +6 -1
  40. package/src/sites/sites.js +20 -3
  41. package/src/styles/chrome-ui.css +59 -30
  42. package/src/testing/e2e.d.ts +35 -4
  43. package/src/testing/e2e.js +67 -5
  44. package/src/testing/fake-model.d.ts +9 -1
  45. package/src/testing/fake-model.js +44 -23
  46. package/src/testing/fake-storage.d.ts +41 -13
  47. package/src/testing/fake-storage.js +163 -51
  48. package/src/testing/index.d.ts +3 -3
  49. package/src/testing/index.js +5 -3
  50. package/src/testing/live-run.d.ts +23 -2
  51. package/src/testing/live-run.js +64 -18
  52. package/src/testing/messages.d.ts +7 -3
  53. package/src/testing/messages.js +50 -9
  54. package/src/testing/package.d.ts +56 -7
  55. package/src/testing/package.js +57 -18
  56. package/src/testing/prompt.d.ts +4 -2
  57. package/src/testing/prompt.js +7 -3
  58. package/src/time/pool.d.ts +2 -1
  59. package/src/time/pool.js +4 -2
  60. package/src/toolbar/theme.d.ts +10 -2
  61. package/src/toolbar/theme.js +12 -4
  62. package/src/ui/menu.d.ts +13 -3
  63. package/src/ui/menu.js +48 -11
  64. package/src/ui/model-row.js +5 -1
  65. package/src/ui/site-list.js +12 -3
  66. package/src/ui/text-dialog.d.ts +3 -2
  67. package/src/ui/text-dialog.js +10 -3
  68. package/src/ui/toast.d.ts +3 -0
  69. package/src/ui/toast.js +23 -1
@@ -307,7 +307,7 @@ button.link:hover { background: none; text-decoration: underline; }
307
307
 
308
308
  /* settings-subpage's header, at the top of its card: the back button, then the subpage's title.
309
309
  <div class="subpage-head"><button class="icon-button" aria-label="Back"><span class="icon icon-arrow-back"></span></button><h2>Cookies</h2></div> */
310
- .subpage-head { display: flex; align-items: center; gap: 8px; min-height: 56px; padding: 0 20px 0 8px; }
310
+ .subpage-head { display: flex; align-items: center; gap: 8px; min-height: 56px; padding-inline: 8px 20px; }
311
311
  .subpage-head h2 { margin: 0; font-size: 15px; font-weight: 400; color: var(--text); overflow-wrap: anywhere; }
312
312
  .subpage-head .icon-button { color: var(--icon-muted); }
313
313
 
@@ -323,13 +323,17 @@ select {
323
323
  width: 200px;
324
324
  max-width: 100%;
325
325
  height: 36px;
326
- padding: 0 20px 0 10px;
326
+ padding-block: 0;
327
+ padding-inline: 10px 20px;
327
328
  border: 1px solid var(--field-border);
328
329
  border-radius: var(--radius);
329
330
  background: var(--select-arrow) calc(100% - 10px) center / 7px no-repeat transparent;
330
331
  cursor: pointer;
331
332
  }
332
333
 
334
+ /* Right to left: the arrow at the other end, as Chrome mirrors its own. */
335
+ select:dir(rtl) { background-position: 10px center; }
336
+
333
337
  select:hover { background-color: var(--hover-subtle); }
334
338
  select:focus-visible { outline: 2px solid var(--primary); outline-offset: -1px; }
335
339
  select:disabled { opacity: 0.38; cursor: default; }
@@ -414,13 +418,13 @@ input.switch::after {
414
418
  content: "";
415
419
  position: absolute;
416
420
  top: 50%;
417
- left: 7px;
421
+ inset-inline-start: 7px;
418
422
  width: 8px;
419
423
  height: 8px;
420
424
  border-radius: 50%;
421
425
  background: var(--switch-outline-off);
422
426
  transform: translate(-50%, -50%);
423
- transition: left 80ms linear, width 80ms linear, height 80ms linear, background-color 80ms linear;
427
+ transition: inset-inline-start 80ms linear, width 80ms linear, height 80ms linear, background-color 80ms linear;
424
428
  }
425
429
 
426
430
  /* The 20px hover halo around the knob. */
@@ -428,7 +432,7 @@ input.switch:hover::before {
428
432
  content: "";
429
433
  position: absolute;
430
434
  top: 50%;
431
- left: 7px;
435
+ inset-inline-start: 7px;
432
436
  width: 20px;
433
437
  height: 20px;
434
438
  border-radius: 50%;
@@ -438,8 +442,10 @@ input.switch:hover::before {
438
442
 
439
443
  input.switch:active::after { width: 10px; height: 10px; }
440
444
  input.switch:checked { background: var(--primary); border-color: var(--primary); }
441
- input.switch:checked::after { left: 17px; width: 12px; height: 12px; background: var(--on-primary); }
442
- input.switch:checked:hover::before { left: 17px; }
445
+ input.switch:checked::after { inset-inline-start: 17px; width: 12px; height: 12px; background: var(--on-primary); }
446
+ input.switch:checked:hover::before { inset-inline-start: 17px; }
447
+ /* Right to left: the knob is measured from the right, so it's centred on its point the other way. */
448
+ input.switch:dir(rtl)::after, input.switch:dir(rtl):hover::before { transform: translate(50%, -50%); }
443
449
  input.switch:checked:active::after { width: 14px; height: 14px; }
444
450
  input.switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
445
451
  input.switch:disabled { opacity: 0.38; cursor: default; }
@@ -458,10 +464,10 @@ input.switch:disabled::before { display: none; }
458
464
  grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--sidebar-width);
459
465
  align-items: center;
460
466
  height: var(--toolbar-height);
461
- padding-right: 12px;
467
+ padding-inline-end: 12px;
462
468
  }
463
469
 
464
- .toolbar-start { display: flex; align-items: center; gap: 12px; padding-left: 20px; min-width: 0; }
470
+ .toolbar-start { display: flex; align-items: center; gap: 12px; padding-inline-start: 20px; min-width: 0; }
465
471
  .toolbar-start h1, .toolbar > h1 {
466
472
  font-size: 22px;
467
473
  font-weight: 500;
@@ -481,7 +487,8 @@ input.switch:disabled::before { display: none; }
481
487
  width: 100%;
482
488
  max-width: 680px;
483
489
  height: 36px;
484
- padding: 0 6px 0 10px;
490
+ padding-block: 0;
491
+ padding-inline: 10px 6px;
485
492
  border-radius: 100px;
486
493
  background: var(--search-bg);
487
494
  color: var(--icon);
@@ -496,7 +503,8 @@ input.switch:disabled::before { display: none; }
496
503
  min-width: 0;
497
504
  height: 100%;
498
505
  min-height: 0;
499
- padding: 0 0 0 4px;
506
+ padding: 0;
507
+ padding-inline-start: 4px;
500
508
  border: 0;
501
509
  border-radius: 0;
502
510
  background: none;
@@ -621,11 +629,13 @@ label.row, .row[actionable] { cursor: pointer; }
621
629
  flex: none;
622
630
  width: 20px;
623
631
  height: 20px;
624
- margin-right: -2px;
632
+ margin-inline-end: -2px;
625
633
  background: var(--icon-muted);
626
634
  mask: var(--icon-arrow-right) center / 20px no-repeat;
627
635
  }
628
636
 
637
+ .link-row:dir(rtl)::after { transform: scaleX(-1); }
638
+
629
639
  .link-row.external::after { mask-image: var(--icon-open-in-new); }
630
640
 
631
641
  /* A list inside a card: each item is a row. */
@@ -653,9 +663,13 @@ label.row, .row[actionable] { cursor: pointer; }
653
663
  align-items: center;
654
664
  gap: 20px;
655
665
  min-height: 40px;
656
- margin: 0 2px 0 1px;
657
- padding: 10px 16px 10px 23px;
658
- border-radius: 0 100px 100px 0;
666
+ margin-block: 0;
667
+ margin-inline: 1px 2px;
668
+ padding-block: 10px;
669
+ padding-inline: 23px 16px;
670
+ border-radius: 0;
671
+ border-start-end-radius: 100px;
672
+ border-end-end-radius: 100px;
659
673
  color: var(--text);
660
674
  font-size: 14px;
661
675
  font-weight: 500;
@@ -678,7 +692,7 @@ label.row, .row[actionable] { cursor: pointer; }
678
692
  .manager { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
679
693
  .layout { flex: 1; min-height: 0; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); }
680
694
  .sidebar { overflow: auto; padding: 8px 0 24px; }
681
- .main { overflow: auto; padding: 24px 32px 96px 17px; min-width: 0; }
695
+ .main { overflow: auto; padding-block: 24px 96px; padding-inline: 17px 32px; min-width: 0; }
682
696
 
683
697
  /* A folder in the sidebar (bookmarks-folder-node): the same pill as the settings menu. */
684
698
  .tree { list-style: none; margin: 0; padding: 0; }
@@ -689,9 +703,13 @@ label.row, .row[actionable] { cursor: pointer; }
689
703
  align-items: center;
690
704
  gap: 8px;
691
705
  min-height: 40px;
692
- margin: 0 3px 0 1px;
693
- padding: 0 4px 0 calc(8px + var(--depth) * 20px);
694
- border-radius: 0 100px 100px 0;
706
+ margin-block: 0;
707
+ margin-inline: 1px 3px;
708
+ padding-block: 0;
709
+ padding-inline: calc(8px + var(--depth) * 20px) 4px;
710
+ border-radius: 0;
711
+ border-start-end-radius: 100px;
712
+ border-end-end-radius: 100px;
695
713
  color: var(--folder-text);
696
714
  font-size: 14px;
697
715
  font-weight: 500;
@@ -724,7 +742,8 @@ label.row, .row[actionable] { cursor: pointer; }
724
742
  align-items: center;
725
743
  gap: 20px;
726
744
  height: 40px;
727
- padding: 0 8px 0 20px;
745
+ padding-block: 0;
746
+ padding-inline: 20px 8px;
728
747
  user-select: none;
729
748
  }
730
749
 
@@ -798,7 +817,8 @@ label.row, .row[actionable] { cursor: pointer; }
798
817
  `hidden`: the popover defaults undone either way. */
799
818
  .toast {
800
819
  position: fixed;
801
- inset: auto auto 0 0;
820
+ inset-block: auto 0;
821
+ inset-inline: 0 auto;
802
822
  display: flex;
803
823
  align-items: center;
804
824
  gap: 32px;
@@ -822,7 +842,7 @@ label.row, .row[actionable] { cursor: pointer; }
822
842
 
823
843
  .toast button {
824
844
  height: auto;
825
- margin-right: -8px;
845
+ margin-inline-end: -8px;
826
846
  padding: 8px;
827
847
  border: 0;
828
848
  background: none;
@@ -836,7 +856,8 @@ label.row, .row[actionable] { cursor: pointer; }
836
856
 
837
857
  .drawer {
838
858
  position: fixed;
839
- inset: 0 auto 0 0;
859
+ inset-block: 0;
860
+ inset-inline: 0 auto;
840
861
  width: 256px;
841
862
  max-width: calc(100vw - 56px);
842
863
  height: 100%;
@@ -851,9 +872,11 @@ label.row, .row[actionable] { cursor: pointer; }
851
872
 
852
873
  .drawer[open] { animation: cr-drawer-in 200ms ease; }
853
874
  .drawer::backdrop { background: rgb(0 0 0 / 0.5); }
854
- .drawer-head { display: flex; align-items: center; gap: 12px; height: var(--toolbar-height); padding-left: 24px; font-size: 22px; font-weight: 500; letter-spacing: 0.25px; }
875
+ .drawer-head { display: flex; align-items: center; gap: 12px; height: var(--toolbar-height); padding-inline-start: 24px; font-size: 22px; font-weight: 500; letter-spacing: 0.25px; }
855
876
 
856
877
  @keyframes cr-drawer-in { from { transform: translateX(-100%); } }
878
+ @keyframes cr-drawer-in-rtl { from { transform: translateX(100%); } }
879
+ .drawer[open]:dir(rtl) { animation-name: cr-drawer-in-rtl; }
857
880
 
858
881
  @media (prefers-reduced-motion: reduce) {
859
882
  .drawer[open] { animation: none; }
@@ -942,7 +965,8 @@ label.row, .row[actionable] { cursor: pointer; }
942
965
 
943
966
  /* .sp-card: rows on a 12px-rounded card, 8px from the panel's edge. */
944
967
  .sp-card {
945
- margin: 0 0 0 8px;
968
+ margin: 0;
969
+ margin-inline-start: 8px;
946
970
  padding: 8px 0;
947
971
  border-radius: 12px;
948
972
  background: var(--sp-card);
@@ -1027,7 +1051,7 @@ label.row, .row[actionable] { cursor: pointer; }
1027
1051
  place-items: center;
1028
1052
  width: 40px;
1029
1053
  height: 40px;
1030
- margin-right: 16px;
1054
+ margin-inline-end: 16px;
1031
1055
  border-radius: 8px;
1032
1056
  background: var(--sp-icon-bg);
1033
1057
  color: var(--text-secondary);
@@ -1057,10 +1081,14 @@ label.row, .row[actionable] { cursor: pointer; }
1057
1081
  display: none;
1058
1082
  align-items: center;
1059
1083
  gap: 4px;
1060
- margin: 0 -4px 0 8px;
1084
+ margin-block: 0;
1085
+ margin-inline: 8px -4px;
1061
1086
  }
1062
1087
 
1088
+ /* Shown too while one of its buttons has its menu open: the menu is modal and takes the focus and
1089
+ the pointer, and the button it opened from (where the focus goes back) would vanish under it. */
1063
1090
  .sp-item:is(:hover, :focus-within) .sp-suffix,
1091
+ .sp-suffix:has([aria-expanded="true"]),
1064
1092
  .sp-suffix.always { display: flex; }
1065
1093
 
1066
1094
  /* .sp-notice (ui/notice.js): a card saying what a list leaves out or can't do; a warning's icon in
@@ -1096,7 +1124,8 @@ button.floating {
1096
1124
  align-items: center;
1097
1125
  gap: 8px;
1098
1126
  height: 40px;
1099
- padding: 8px 16px 8px 12px;
1127
+ padding-block: 8px;
1128
+ padding-inline: 12px 16px;
1100
1129
  border: 0;
1101
1130
  border-radius: 8px;
1102
1131
  background: var(--tonal-bg);
@@ -1118,6 +1147,6 @@ button.floating .icon { width: 20px; height: 20px; }
1118
1147
  /* ------------------------------------------------------------ Narrow */
1119
1148
 
1120
1149
  @media (max-width: 720px) {
1121
- .toolbar { grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; padding-right: 8px; }
1122
- .toolbar-start { padding-left: 16px; }
1150
+ .toolbar { grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; padding-inline-end: 8px; }
1151
+ .toolbar-start { padding-inline-start: 16px; }
1123
1152
  }
@@ -125,6 +125,33 @@ export declare function extensionProfile(chromium: BrowserType, dir: string, { p
125
125
  * await page.goto(url('options/options.html'));
126
126
  */
127
127
  export declare function launchExtension(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension>;
128
+ /**
129
+ * Stop the extension's service worker, as Chrome does after 30 seconds idle: its globals,
130
+ * timers and anything else kept in memory are gone; storage, alarms and registered listeners
131
+ * stay. Chrome never does this by itself in an e2e run (Playwright's debugger keeps the worker
132
+ * alive), so a check that state survives a restart has to ask for it. Done through the
133
+ * DevTools protocol from a page of the extension (`ServiceWorker.stopWorker`), and, unless
134
+ * `start` is false, the worker is started again with no event (`ServiceWorker.startWorker`).
135
+ *
136
+ * `launched.worker` stays the handle to use: Playwright keeps it across the restart. Anything a
137
+ * test put in the old worker (`recordIconCalls`, a patched `chrome.*` function) is gone with it.
138
+ *
139
+ * @param {Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>} launched - From {@link launchExtension}.
140
+ * @param {{start?: boolean, page?: string}} [options] - `start: false` leaves the worker stopped, for
141
+ * the next event (a message, an alarm, a tab) to start it as on a person's computer; `page`: a file
142
+ * of the extension to open for the protocol session (default `manifest.json`).
143
+ * @returns {Promise<void>} Once the worker has stopped (and, with `start`, is running again).
144
+ * @throws {Error} When the worker isn't running, or doesn't stop or start in 10 s.
145
+ *
146
+ * @example
147
+ * await worker.evaluate(() => { globalThis.cache = { tab: 7 }; });
148
+ * await stopWorker(launched);
149
+ * await worker.evaluate(() => globalThis.cache); // undefined: what wasn't stored is gone
150
+ */
151
+ export declare function stopWorker({ context, worker, extensionId, url }: Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>, { start, page: file }?: {
152
+ start?: boolean;
153
+ page?: string;
154
+ }): Promise<void>;
128
155
  /**
129
156
  * Emulate colour schemes in the offscreen document: one CDP session attached
130
157
  * to it, reused for every change.
@@ -137,10 +164,12 @@ export declare function launchExtension(chromium: BrowserType, dir: string, opti
137
164
  export declare function offscreenEmulator(cdp: CDPSession, suffix?: string): Promise<(scheme: 'light' | 'dark') => Promise<unknown>>;
138
165
  /**
139
166
  * Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
140
- * Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
167
+ * Chrome has taken it (a path Chrome can't load fails, and isn't recorded):
168
+ * its `path`, or `'imageData'` for an icon given as pixels. The record lives
169
+ * in the worker: it's gone when the worker restarts ({@link stopWorker}).
141
170
  *
142
171
  * @param {Worker} worker - The extension's service worker.
143
- * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
172
+ * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded icons, oldest first.
144
173
  */
145
174
  export declare function recordIconCalls(worker: Worker): Promise<() => Promise<unknown[]>>;
146
175
  /** How long one check may take before it's failed, in ms: long enough for a slow real-browser check, short of a hang. */
@@ -159,7 +188,8 @@ export type CheckRunner = {
159
188
  */
160
189
  fail: (name: string) => void;
161
190
  /**
162
- * Print the tally, and exit with 1 if anything failed.
191
+ * Print the tally, and exit with 1 if anything failed or no check ran at all
192
+ * (a script that returned early, or whose filter matched nothing, hasn't passed).
163
193
  */
164
194
  finish: () => void;
165
195
  };
@@ -173,7 +203,8 @@ export type CheckRunner = {
173
203
  * whether it passed.
174
204
  * @property {(name: string) => void} fail Count a failure found outside any check (a page's
175
205
  * uncaught error, say): printed now, listed and counted at the end.
176
- * @property {() => void} finish Print the tally, and exit with 1 if anything failed.
206
+ * @property {() => void} finish Print the tally, and exit with 1 if anything failed or no check ran at all
207
+ * (a script that returned early, or whose filter matched nothing, hasn't passed).
177
208
  */
178
209
  /**
179
210
  * Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
@@ -96,7 +96,11 @@ export async function extensionProfile(chromium, dir, { prefix = 'extension-', a
96
96
  ...options,
97
97
  args: [`--disable-extensions-except=${dir}`, `--load-extension=${dir}`, ...args],
98
98
  });
99
- const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker');
99
+ const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker', { timeout: 15_000 }).catch(async () => {
100
+ // Chrome refused the extension (a manifest or `_locales` error) or it has no worker: nothing to wait for.
101
+ await context.close();
102
+ throw new Error(`The extension at ${dir} didn't start a service worker: Chrome may have refused to load it (check its manifest and _locales at chrome://extensions)`);
103
+ });
100
104
  const extensionId = new URL(worker.url()).host;
101
105
  return {
102
106
  context,
@@ -137,6 +141,56 @@ export async function launchExtension(chromium, dir, options = {}) {
137
141
  };
138
142
  }
139
143
 
144
+ /**
145
+ * Stop the extension's service worker, as Chrome does after 30 seconds idle: its globals,
146
+ * timers and anything else kept in memory are gone; storage, alarms and registered listeners
147
+ * stay. Chrome never does this by itself in an e2e run (Playwright's debugger keeps the worker
148
+ * alive), so a check that state survives a restart has to ask for it. Done through the
149
+ * DevTools protocol from a page of the extension (`ServiceWorker.stopWorker`), and, unless
150
+ * `start` is false, the worker is started again with no event (`ServiceWorker.startWorker`).
151
+ *
152
+ * `launched.worker` stays the handle to use: Playwright keeps it across the restart. Anything a
153
+ * test put in the old worker (`recordIconCalls`, a patched `chrome.*` function) is gone with it.
154
+ *
155
+ * @param {Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>} launched - From {@link launchExtension}.
156
+ * @param {{start?: boolean, page?: string}} [options] - `start: false` leaves the worker stopped, for
157
+ * the next event (a message, an alarm, a tab) to start it as on a person's computer; `page`: a file
158
+ * of the extension to open for the protocol session (default `manifest.json`).
159
+ * @returns {Promise<void>} Once the worker has stopped (and, with `start`, is running again).
160
+ * @throws {Error} When the worker isn't running, or doesn't stop or start in 10 s.
161
+ *
162
+ * @example
163
+ * await worker.evaluate(() => { globalThis.cache = { tab: 7 }; });
164
+ * await stopWorker(launched);
165
+ * await worker.evaluate(() => globalThis.cache); // undefined: what wasn't stored is gone
166
+ */
167
+ export async function stopWorker({ context, worker, extensionId, url }, { start = true, page: file = 'manifest.json' } = {}) {
168
+ const page = await context.newPage();
169
+ try {
170
+ await page.goto(url(file));
171
+ const cdp = await context.newCDPSession(page);
172
+ /** @type {Map<string, {versionId: string, scriptURL: string, runningStatus: string}>} */
173
+ const versions = new Map();
174
+ cdp.on('ServiceWorker.workerVersionUpdated', (event) => {
175
+ for (const version of event.versions) versions.set(version.versionId, version);
176
+ });
177
+ await cdp.send('ServiceWorker.enable');
178
+ const own = () => [...versions.values()].filter((version) => version.scriptURL === worker.url());
179
+ const running = await until(() => own().find((version) => version.runningStatus === 'running'), 'the service worker to be running', 10_000);
180
+ await cdp.send('ServiceWorker.stopWorker', { versionId: running.versionId });
181
+ await until(() => own().every((version) => version.runningStatus === 'stopped'), 'the service worker to stop', 10_000);
182
+ if (start) {
183
+ await cdp.send('ServiceWorker.startWorker', { scopeURL: `chrome-extension://${extensionId}/` });
184
+ await until(() => own().some((version) => version.runningStatus === 'running'), 'the service worker to start again', 10_000);
185
+ // Running, and answering: its script has run again.
186
+ await worker.evaluate(() => true);
187
+ }
188
+ await cdp.detach();
189
+ } finally {
190
+ await page.close();
191
+ }
192
+ }
193
+
140
194
  /**
141
195
  * Emulate colour schemes in the offscreen document: one CDP session attached
142
196
  * to it, reused for every change.
@@ -164,10 +218,12 @@ export async function offscreenEmulator(cdp, suffix = '/offscreen/offscreen.html
164
218
 
165
219
  /**
166
220
  * Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
167
- * Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
221
+ * Chrome has taken it (a path Chrome can't load fails, and isn't recorded):
222
+ * its `path`, or `'imageData'` for an icon given as pixels. The record lives
223
+ * in the worker: it's gone when the worker restarts ({@link stopWorker}).
168
224
  *
169
225
  * @param {Worker} worker - The extension's service worker.
170
- * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
226
+ * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded icons, oldest first.
171
227
  */
172
228
  export async function recordIconCalls(worker) {
173
229
  await worker.evaluate(() => {
@@ -176,7 +232,7 @@ export async function recordIconCalls(worker) {
176
232
  const real = chrome.action.setIcon.bind(chrome.action);
177
233
  chrome.action.setIcon = /** @type {typeof chrome.action.setIcon} */ (async (/** @type {chrome.action.TabIconDetails} */ details) => {
178
234
  await real(details);
179
- calls.push(details.path);
235
+ calls.push(details.path ?? (details.imageData ? 'imageData' : undefined));
180
236
  });
181
237
  });
182
238
  return () => worker.evaluate(() => /** @type {{iconCalls: unknown[]}} */ (/** @type {unknown} */ (globalThis)).iconCalls);
@@ -195,7 +251,8 @@ export const CHECK_MS = 60_000;
195
251
  * whether it passed.
196
252
  * @property {(name: string) => void} fail Count a failure found outside any check (a page's
197
253
  * uncaught error, say): printed now, listed and counted at the end.
198
- * @property {() => void} finish Print the tally, and exit with 1 if anything failed.
254
+ * @property {() => void} finish Print the tally, and exit with 1 if anything failed or no check ran at all
255
+ * (a script that returned early, or whose filter matched nothing, hasn't passed).
199
256
  */
200
257
 
201
258
  /**
@@ -238,6 +295,11 @@ export function checkRunner({ timeout: runnerTimeout = CHECK_MS } = {}) {
238
295
  },
239
296
  finish() {
240
297
  console.log(`\n${passed} passed, ${failures.length} failed`);
298
+ if (!passed && !failures.length) {
299
+ console.log(' No checks ran.');
300
+ process.exit(1);
301
+ return;
302
+ }
241
303
  if (!failures.length) return;
242
304
  for (const failure of failures) console.log(` - ${failure}`);
243
305
  process.exit(1);
@@ -4,7 +4,9 @@
4
4
  * worker's or page's `globalThis`: Playwright's Chromium has no model). It
5
5
  * acts out what a real Chrome does: a model still to download (with
6
6
  * progress), one this computer can't run, a slow first session (the cold
7
- * start), a slow answer, an answer cut short by its signal, and a failure.
7
+ * start), a slow answer, an answer cut short by its signal, a failure, a
8
+ * session that's been destroyed (it answers nothing more), and a download
9
+ * asked for without a click (Chrome refuses it).
8
10
  * Moved here from Better Downloads' tests, so every extension that uses the
9
11
  * model tests against the same one.
10
12
  *
@@ -38,6 +40,12 @@ export type FakeModelState = {
38
40
  * Whether a download fails part way.
39
41
  */
40
42
  failDownload: boolean;
43
+ /**
44
+ * Whether a click is behind a `create()` that has to download the
45
+ * model (Chrome refuses one without: `NotAllowedError`). `null`, the default, reads the page's own
46
+ * `navigator.userActivation` (and takes it as given where there's none: Node, a worker).
47
+ */
48
+ userActivation: boolean | null;
41
49
  };
42
50
  export type FakeModelLog = {
43
51
  /**
@@ -4,7 +4,9 @@
4
4
  * worker's or page's `globalThis`: Playwright's Chromium has no model). It
5
5
  * acts out what a real Chrome does: a model still to download (with
6
6
  * progress), one this computer can't run, a slow first session (the cold
7
- * start), a slow answer, an answer cut short by its signal, and a failure.
7
+ * start), a slow answer, an answer cut short by its signal, a failure, a
8
+ * session that's been destroyed (it answers nothing more), and a download
9
+ * asked for without a click (Chrome refuses it).
8
10
  * Moved here from Better Downloads' tests, so every extension that uses the
9
11
  * model tests against the same one.
10
12
  *
@@ -25,6 +27,9 @@
25
27
  * @property {(prompt: string, options: PromptOptions) => string} answer The answer to a prompt.
26
28
  * @property {boolean} failPrompt Whether answers fail (the model gave up).
27
29
  * @property {boolean} failDownload Whether a download fails part way.
30
+ * @property {boolean | null} userActivation Whether a click is behind a `create()` that has to download the
31
+ * model (Chrome refuses one without: `NotAllowedError`). `null`, the default, reads the page's own
32
+ * `navigator.userActivation` (and takes it as given where there's none: Node, a worker).
28
33
  */
29
34
 
30
35
  /**
@@ -77,7 +82,7 @@ function wait(ms, signal) {
77
82
  export function fakeLanguageModel(initial = {}) {
78
83
  /** @type {FakeModelState} */
79
84
  const state = {
80
- availability: 'available', createMs: 0, answerMs: 0, answer: () => 'Fake answer', failPrompt: false, failDownload: false, ...initial,
85
+ availability: 'available', createMs: 0, answerMs: 0, answer: () => 'Fake answer', failPrompt: false, failDownload: false, userActivation: null, ...initial,
81
86
  };
82
87
  /** @type {FakeModelLog} */
83
88
  const log = { availability: [], created: [], prompts: [], promptOptions: [], cloned: 0, aborted: 0, destroyed: 0 };
@@ -87,27 +92,40 @@ export function fakeLanguageModel(initial = {}) {
87
92
  *
88
93
  * @returns {LanguageModelSession} It.
89
94
  */
90
- const session = () => ({
91
- async prompt(prompt, options = {}) {
92
- log.prompts.push(prompt);
93
- log.promptOptions.push(options);
94
- try {
95
- await wait(state.answerMs, options.signal);
96
- } catch (error) {
97
- log.aborted++;
98
- throw error;
99
- }
100
- if (state.failPrompt) throw new DOMException('The model failed', 'UnknownError');
101
- return state.answer(prompt, options);
102
- },
103
- async clone() {
104
- log.cloned++;
105
- return session();
106
- },
107
- destroy() {
108
- log.destroyed++;
109
- },
110
- });
95
+ const session = () => {
96
+ let destroyed = false;
97
+ /** Chrome's refusal to use a session that's been destroyed. */
98
+ const refuseDestroyed = () => {
99
+ if (destroyed) throw new DOMException('The session has been destroyed.', 'InvalidStateError');
100
+ };
101
+ return /** @type {LanguageModelSession} */ ({
102
+ async prompt(prompt, options = {}) {
103
+ refuseDestroyed();
104
+ log.prompts.push(prompt);
105
+ log.promptOptions.push(options);
106
+ try {
107
+ await wait(state.answerMs, options.signal);
108
+ } catch (error) {
109
+ log.aborted++;
110
+ throw error;
111
+ }
112
+ // Destroyed while it was answering: Chrome stops the answer.
113
+ if (destroyed) throw new DOMException('The session has been destroyed.', 'AbortError');
114
+ if (state.failPrompt) throw new DOMException('The model failed', 'UnknownError');
115
+ return state.answer(prompt, options);
116
+ },
117
+ async clone() {
118
+ refuseDestroyed();
119
+ log.cloned++;
120
+ return session();
121
+ },
122
+ destroy() {
123
+ if (destroyed) return;
124
+ destroyed = true;
125
+ log.destroyed++;
126
+ },
127
+ });
128
+ };
111
129
 
112
130
  return {
113
131
  state,
@@ -121,6 +139,9 @@ export function fakeLanguageModel(initial = {}) {
121
139
  log.created.push(options);
122
140
  if (state.availability === 'unavailable') throw new DOMException('Not supported', 'NotSupportedError');
123
141
  if (state.availability !== 'available') {
142
+ // Chrome starts a download only from a click (transient user activation).
143
+ const active = state.userActivation ?? /** @type {{navigator?: {userActivation?: {isActive: boolean}}}} */ (globalThis).navigator?.userActivation?.isActive ?? true;
144
+ if (!active) throw new DOMException('Requires a user gesture when availability is "downloading" or "downloadable".', 'NotAllowedError');
124
145
  // A download, reported as Chrome reports it, then the model is here.
125
146
  const monitor = new EventTarget();
126
147
  options.monitor?.(monitor);
@@ -1,29 +1,60 @@
1
1
  /**
2
- * An in-memory `chrome.storage` for tests, behaving as Chrome does: values
3
- * are stored as JSON, `onChanged` fires only for values that changed, and
4
- * sync refuses an item over its 8,192-byte quota and more than 120 writes
5
- * (set or remove) a minute. Tests then check outcomes (what's stored), not
2
+ * An in-memory `chrome.storage` for tests, behaving as Chrome does (each point
3
+ * checked against Chromium): values are stored as Chrome converts them (what
4
+ * JSON can't hold is dropped, a `Date` is `{}`), `get` takes a key, keys, an
5
+ * object of defaults or nothing, `onChanged` fires only for values that
6
+ * changed (whatever order an object's keys come in), and every area has
7
+ * Chrome's quotas. Sync refuses an item over 8,192 bytes as Chrome counts them
8
+ * (`syncItemBytes`: UTF-8, `<` six bytes), more than 102,400 bytes or 512
9
+ * items in all, and more than 120 calls a minute or 1,800 an hour of `set`,
10
+ * `remove` or `clear` (each counted by itself, refused calls included); local
11
+ * and session hold 10 MB. Tests then check outcomes (what's stored), not
6
12
  * calls.
7
13
  *
8
14
  * @module testing/fake-storage
9
15
  */
10
16
  import type { StorageChange } from '../settings/storage.js';
11
17
  /** @import { StorageChange } from '../settings/storage.js' */
12
- /** Chrome Sync's write limit: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
18
+ /** Chrome Sync's write limit a minute: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
13
19
  export declare const SYNC_WRITES_PER_MINUTE = 120;
20
+ /** Chrome Sync's write limit an hour: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR`. */
21
+ export declare const SYNC_WRITES_PER_HOUR = 1800;
22
+ /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
23
+ export declare const SYNC_QUOTA_BYTES = 102400;
24
+ /** The most items sync holds (`chrome.storage.sync.MAX_ITEMS`). */
25
+ export declare const SYNC_MAX_ITEMS = 512;
26
+ /** What local and session each hold (`chrome.storage.local.QUOTA_BYTES`), without `unlimitedStorage`. */
27
+ export declare const LOCAL_QUOTA_BYTES = 10485760;
14
28
  export type ChangeListener = (changes: Record<string, StorageChange>, area: string) => unknown;
29
+ export type FakeEvent<L extends (...args: any[]) => unknown> = {
30
+ addListener: (listener: L) => void;
31
+ removeListener: (listener: L) => void;
32
+ hasListener: (listener: L) => boolean;
33
+ };
15
34
  export type FakeArea = {
16
35
  /**
17
36
  * What it holds, to inspect (and seed) in tests.
18
37
  */
19
38
  data: Record<string, any>;
20
39
  /**
21
- * How many writes it has taken.
40
+ * How many writes (`set`, `remove`, `clear`) it has taken.
22
41
  */
23
42
  writes: number;
24
- get: (keys?: string | string[] | null) => Promise<Record<string, any>>;
43
+ /**
44
+ * A key's or keys' items, an object's keys with its values for the missing ones, or everything.
45
+ */
46
+ get: (keys?: string | string[] | Record<string, unknown> | null) => Promise<Record<string, any>>;
25
47
  set: (items: Record<string, unknown>) => Promise<void>;
26
48
  remove: (keys: string | string[]) => Promise<void>;
49
+ clear: () => Promise<void>;
50
+ /**
51
+ * The bytes the keys' items (or all) take.
52
+ */
53
+ getBytesInUse: (keys?: string | string[] | null) => Promise<number>;
54
+ /**
55
+ * This area's changes.
56
+ */
57
+ onChanged: FakeEvent<(changes: Record<string, StorageChange>) => unknown>;
27
58
  };
28
59
  export type FakeChromeStorage = {
29
60
  /**
@@ -41,16 +72,12 @@ export type FakeChromeStorage = {
41
72
  /**
42
73
  * `chrome.storage.onChanged`.
43
74
  */
44
- onChanged: {
45
- addListener(listener: ChangeListener): void;
46
- };
75
+ onChanged: FakeEvent<ChangeListener>;
47
76
  /**
48
77
  * Waits for every listener, including the changes those listeners make.
49
78
  */
50
79
  settled: () => Promise<void>;
51
80
  };
52
- /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
53
- export declare const SYNC_QUOTA_BYTES = 102400;
54
81
  /**
55
82
  * Make a fake `chrome.storage`.
56
83
  *
@@ -58,8 +85,9 @@ export declare const SYNC_QUOTA_BYTES = 102400;
58
85
  * @param {Record<string, unknown>} [initial.local] - What local holds.
59
86
  * @param {Record<string, unknown>} [initial.sync] - What sync holds.
60
87
  * @param {Record<string, unknown>} [initial.session] - What session holds.
61
- * @param {() => number} [initial.now] - The clock sync's write limit reads. Default `Date.now`.
88
+ * @param {() => number} [initial.now] - The clock sync's write limits read. Default `Date.now`.
62
89
  * @returns {FakeChromeStorage} The fake.
90
+ *
63
91
  * @example
64
92
  * const storage = fakeChromeStorage({ sync: { days: 3 } });
65
93
  * await store.save(storage, { days: 5 });