From c20d3607789bf49d7df63a3b6bdb44fb625a9405 Mon Sep 17 00:00:00 2001 From: Max Milton Date: Sun, 15 Dec 2024 00:03:30 +0900 Subject: [PATCH 1/4] test: Validate HTML files --- bun.lockb | Bin 114212 -> 114588 bytes package.json | 2 +- test/unit/index.test.ts | 12 +++++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/bun.lockb b/bun.lockb index 30fb9bf1c710ff5a60db7a2cfbdd82c12e09f061..61d3bb853e3120a313191eea3c3d3c3637259ac0 100755 GIT binary patch delta 2395 zcmc&$dr(wW7{3R3>?IHp1lE-|3d>_*6Ffac^zU9i!>@_TK9qr)_p@lO#EVkt2-) zgRJ?z*^=Z9-36v~FM?@)J(%vd1B`Xt%2&bggu|jFq-HmLD1V?*t#ByziydFLvZ-oh zK?;wZ9c5RCQfKl9rg-@wtmiRD;-wx2*@6;NpzOIm(jd1fO2ze_1{;t0{5w;kya3gv zTPHMz8RQPA1yGFtXfoJDnD`sniSl~nZr(jK}iSgr)=LtN(j(XriYT*A%zh zHZQ5muH}VwI{n>keoLlZ7^n3=AI95Sk2b6yb~(R9${VeJXZ^hk2j4b$8rsXX_V*Q~~!eO13DZ7j22b@q#xVcT!K)>Gd4{{3?qDW10% ztiF1x^OdUOHTN%jf9}_N(@E4mcVl`({OR{@b&cM}0&nm^c3xYF~#lPwz-$zI=a&k*9V#^S`0|`HW5@3*bj^4dj2| z8pJcZjBG4FiR(DdZWviGH{lw>8*$Zg`KFPDHs#;EuDkUoSYK$8R!zM`z{_hL9!d`N zBwl#y@D$a-%q_u73E9CpZQw?dTJce<&Qg4%6`uoqvlX94^w1Jxl#qx7S%WvA1UR4r9E0-#c8jUz~_0cwF`z)_$MI1JFMPOm*34SN7OO11-A#T6~PH#Qb# zG>`yH1mb{rAO@HO7=UNR$D!=7nkuBqsX{tnb^%*}&0=#H3-&IAy8u`WfyYXp6Uz87ixA$hI%srg&=iQ++DZ{we%x1AVk#1&TtVygjvv|$m=iF)P zmK@vT4Gu_;)kTljO^{l|bu+7Ar6SY9D#oPZ3_&xLpX&CAx$^7mc3Y;`#p$9^uO(YN zv@kEt;P>3+C$ZNz@p{eUj2*&1m#JC3hzDr~{}4=$IH@Y?_UV0`*Cm!AA!zU)MAojP zjSVxu3VfWP${*OG)O>L&mrW5rrQuD+-c4(f)petLlE~zAcO-W&K*|MdP m1!X105zF&etX#B0e0Gk-s-JK~M*J&0=RDIlyEn5D4*vj%5bX#6 delta 2103 zcmd5-ZBSHY6uxKW>u!U}*Rm@D%J)Z$I>N4q*e(fyX!#K=3(K-FV~X17lrw$klxtIs zW-7jpFJL4oP1%T}t%XtK5F=AR%oeo9A5Ky_+K;ptn-MAKIqU`f+0V|*bD#5^^SS?m2eLZGKdIR{ZTZ=Q+ijm64t4ysH)!DZ_B&Op4)u`2 zmeu4)B;A>pz6;AQhKy5`op&o0Wd?YeBfvw@)1l4C8^O*hMKOU(!4cpH;Z!%&$Fsq(GsgLVUM*2e-2eF3Y`V&D7=*ub9%F_>I-PB28x8;Y1Lh<5)Z1EstLGX z0fMTqFR-dksPc&k-ejxVspUv`qpj*mbcg_^vkFF~iYZkM^~{ty0mW4UBkw`2o>IlG zKudw#L8z&Dx*?aZyLLG2^=Wvcd$117?`nJi%uCax@j-A1bTbDvX14_w9r%dGZQwBI z4>|eY7>oMoK%aem-~Xul5-lXl@&+ecMnjW2(5)3*Dy&Mkjd^wey= z>W`YUz3-Iw?%>b;Bu9=|?x&nzlb<&GY{KvZ!;Y_LR4o<-zY0EEn)IW@ zVt&gUi2*jl;b|FK44)&V9f$@pwU~_^*ln{+$fD{p?vy*532+-cpKrs*?0w)U&<=b6 z90QI69RMFkKC%Mfqy3J&kVUsrbK&Lz7QhND0`h^yKmo7>D3tzeI%?vsxNGi`5B)yi zP2erL`*BK-e-7adKqF8OGywRxD7AoFUc``yC2$LYrN9$_T?U(Jp*j;qc!$MEyP0D3 zW5|pH3dsgDC7Hg2+XL_m-w*5p-T>Z~Z5STzfm;Wx0#?gj6b$Z!drFR&=}D6db!!1P zkPT!3pCWx)R^(8#ngaVKc@+RdLT7g-Q z_?%CG<{ij>5b|7y&j@e5BCs9cTA{Kjj}oUR!M)Ba06SJEyYnbZUeBXuvUI&-q1<$` zNSBLd%Nw;6so#Mg4im fKyYtQ*P422s|>c}Y+L`T)Ssh8U3<^b%+P-U{Vbb1 diff --git a/package.json b/package.json index c36bd24a..fb87a1aa 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@eslint/js": "9.17.0", "@maxmilton/eslint-config": "0.0.8", "@maxmilton/stylelint-config": "0.1.2", - "@maxmilton/test-utils": "0.0.6", + "@maxmilton/test-utils": "0.0.7", "@playwright/test": "1.49.1", "@types/bun": "1.1.14", "@types/chrome": "0.0.287", diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 343d5c2b..12871f86 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { readdir } from 'node:fs/promises'; +import { validate } from '@maxmilton/test-utils/html'; describe('dist files', () => { // FIXME: The bun file type is just inferred from the file extension, not the @@ -49,8 +50,17 @@ describe('dist files', () => { const distDir = await readdir('dist'); expect(distDir).toHaveLength(distFiles.length); }); + + test.each(distFiles.filter(([filename]) => filename.endsWith('.html')))( + '%s contains valid HTML', + async (filename) => { + const file = Bun.file(`dist/${filename}`); + const html = await file.text(); + const result = validate(html); + expect(result.valid).toBeTrue(); + }, + ); }); -// TODO: HTML files should be valid HTML // TODO: HTML files have correct title // TODO: HTML files have correct JS and CSS file references From 76f2475cedfe630905292aaf39ed36ca471e404c Mon Sep 17 00:00:00 2001 From: Max Milton Date: Sun, 15 Dec 2024 00:04:30 +0900 Subject: [PATCH 2/4] test: Better service worker e2e tests --- test/e2e/fixtures.ts | 6 +++--- test/e2e/sw.spec.ts | 14 ++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts index 529961f2..404e87f8 100644 --- a/test/e2e/fixtures.ts +++ b/test/e2e/fixtures.ts @@ -28,11 +28,11 @@ export const test = baseTest.extend<{ await context.close(); }, async extensionId({ context }, use) { - let [background] = context.serviceWorkers(); + let [sw] = context.serviceWorkers(); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - background ??= await context.waitForEvent('serviceworker'); + sw ??= await context.waitForEvent('serviceworker', { timeout: 200 }); - const extensionId = background.url().split('/')[2]; + const extensionId = sw.url().split('/')[2]; await use(extensionId); }, }); diff --git a/test/e2e/sw.spec.ts b/test/e2e/sw.spec.ts index 47b20940..f25018b8 100644 --- a/test/e2e/sw.spec.ts +++ b/test/e2e/sw.spec.ts @@ -1,11 +1,9 @@ import { expect, test } from './fixtures'; -test('background service worker', async ({ context }) => { - let [background] = context.serviceWorkers(); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - background ??= await context.waitForEvent('serviceworker'); - - // FIXME: Better assertions - - expect(background).toBeTruthy(); +test('has a single background service worker (sw.js)', async ({ context, extensionId }) => { + const workers = context.serviceWorkers(); + expect(workers).toHaveLength(1); + expect(workers[0]?.url()).toBe(`chrome-extension://${extensionId}/sw.js`); }); + +// TODO: Check there are no console messages or unhandled errors in the worker. From 063ae9c3aa3d486519870cc0e81fd5ca7fde2c8f Mon Sep 17 00:00:00 2001 From: Max Milton Date: Sun, 15 Dec 2024 00:04:48 +0900 Subject: [PATCH 3/4] test: Better condition check --- test/unit/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 12871f86..6621dbe6 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -35,7 +35,7 @@ describe('dist files', () => { expect(file.type).toBe(type); // TODO: Keep this? Type seems to be resolved from the file extension, not the file data. }); - if (minBytes != null && maxBytes != null) { + if (minBytes !== undefined && maxBytes !== undefined) { test('is within expected file size limits', () => { expect.assertions(2); expect(file.size).toBeGreaterThan(minBytes); From 99596a8c2906b5cb1bdc12b8172ebf1d4d80da77 Mon Sep 17 00:00:00 2001 From: Max Milton Date: Sun, 15 Dec 2024 00:14:44 +0900 Subject: [PATCH 4/4] feat: Add settings sync (#2208) * chore: Tweak package.json meta data * chore: Update dependencies * chore: CI yml formatting * feat: Minifest tweaks * chore: Tweak Biome config * feat: Minor tweaks * feat: Byte savings + faster search * chore: Refactor build * chore: Minor tweaks * feat: Use new stage1 fragment API * chore: Fix detecting service worker in e2e tests * test: Validate HTML files * test: Better service worker e2e tests * test: Better condition check * feat: Add settings sync * test: Add settings sync tests + small improvements --- README.md | 5 +- src/settings.ts | 145 +++++++++++++++++++++++++++++++++++-- src/sw.ts | 35 ++++++++- src/types.ts | 9 +++ test/e2e/settings.spec.ts | 7 +- test/setup.ts | 16 +++- test/unit/index.test.ts | 4 +- test/unit/newtab.test.ts | 6 +- test/unit/settings.test.ts | 4 +- test/unit/sw.test.ts | 83 ++++++++++++++++++++- 10 files changed, 289 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e0a56a22..3bacef75 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,13 @@ What started as an experiment to play with the Chrome browser APIs and explore w - Distraction-free, minimal design aesthetic with multiple themes. - A list of your open tabs, recently closed tabs, and top sites. - Search tabs, bookmarks, history, and top sites in one place. -- Simple bookmarks bar. - Links to frequently used destinations in your browser. +- Simple bookmarks bar. +- Customisable UI. +- Optional automatic or manual settings sync between browsers. ### Design goals - | Issue | Why / How | | --- | --- | | Access | Still have access to common things like the bookmarks bar etc. | diff --git a/src/settings.ts b/src/settings.ts index 63501ce7..808599b1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,7 +1,12 @@ import { append, clone, collect, h } from 'stage1'; import { compile } from 'stage1/macro' with { type: 'macro' }; import { reconcile } from 'stage1/reconcile/non-keyed'; -import type { SectionOrderItem, ThemesData } from './types'; +import type { + SectionOrderItem, + SyncStorageData, + ThemesData, + UserStorageData, +} from './types'; import { DEFAULT_SECTION_ORDER, storage } from './utils'; // TODO: Show errors in the UI. @@ -10,6 +15,7 @@ import { DEFAULT_SECTION_ORDER, storage } from './utils'; interface SettingsState { order: [SectionOrderItem[], SectionOrderItem[]]; + pushSyncData?(forceUpdate?: boolean): Promise; } type ItemIndex = [listIndex: 0 | 1, itemIndex: number]; @@ -28,6 +34,19 @@ const themesData = fetch('themes.json').then( (response) => response.json() as Promise, ); +const supportsSync = async (): Promise => { + try { + await chrome.storage.sync.set({ _: 1 }); + await chrome.storage.sync.remove('_'); + if (chrome.runtime.lastError) { + return false; + } + return true; + } catch { + return false; + } +}; + type SectionComponent = HTMLLIElement; interface SectionRefs { @@ -102,17 +121,23 @@ const SectionItem = ( }; interface Refs { - feedback: HTMLDivElement; + feedback: Text; theme: HTMLSelectElement; b: HTMLInputElement; se: HTMLUListElement; sd: HTMLUListElement; reset: HTMLButtonElement; + + feedback2: Text; + sync: HTMLInputElement; + pull: HTMLButtonElement; + push: HTMLButtonElement; + clear: HTMLButtonElement; } const meta = compile(`
-
+
@feedback
@@ -151,6 +176,29 @@ const meta = compile(`
+ +
+ +

Experimental

+ +

Sync Settings

+ +
+ @feedback2 +
+ +
+ + Sync on profile startup (requires sign-in) +
+ +
+ + + +
`); const view = h(meta.html); @@ -192,8 +240,10 @@ const Settings = () => { }); if (themeName === DEFAULT_THEME) { - void chrome.storage.local.remove('tn'); + await chrome.storage.local.remove('tn'); } + + void state.pushSyncData?.(); }; const updateOrder = (order: SettingsState['order'], skipSave?: boolean) => { @@ -214,6 +264,8 @@ const Settings = () => { o: order[0], }); } + + void state.pushSyncData?.(); } }; @@ -234,15 +286,18 @@ const Settings = () => { refs.theme.onchange = () => updateTheme(refs.theme.value); - refs.b.onchange = () => { + refs.b.onchange = async () => { + // eslint-disable-next-line unicorn/prefer-ternary if (refs.b.checked) { // When value is same as default, we don't need to store it - void chrome.storage.local.remove('b'); + await chrome.storage.local.remove('b'); } else { - void chrome.storage.local.set({ + await chrome.storage.local.set({ b: true, }); } + + void state.pushSyncData?.(); }; // eslint-disable-next-line no-multi-assign @@ -266,10 +321,84 @@ const Settings = () => { (item) => !orderEnabled.includes(item), ); - void updateTheme(themeName); + refs.theme.value = themeName; refs.b.checked = !storage.b; updateOrder([orderEnabled, orderDisabled], true); + /* ********************************** */ + // Experimental sync settings feature // + /* ********************************** */ + + refs.sync.checked = !!storage.s; + + const updateSync = (syncData: SyncStorageData) => { + if (syncData.ts) { + refs.feedback2.nodeValue = `Sync data found (last updated: ${new Date( + syncData.ts, + ).toLocaleString()})`; + refs.pull.disabled = false; + refs.clear.disabled = false; + } else { + refs.feedback2.nodeValue = 'No sync data found'; + refs.pull.disabled = true; + refs.clear.disabled = true; + } + + refs.push.disabled = false; + refs.sync.disabled = false; + + refs.sync.onchange = () => { + if (refs.sync.checked) { + void chrome.storage.local.set({ + s: true, + }); + // @ts-expect-error - doesn't need event argument + refs.pull.onclick?.(); + } else { + void chrome.storage.local.remove('s'); + } + }; + + refs.pull.onclick = () => { + if (syncData.data) { + void chrome.storage.local.set(syncData.data); + void updateTheme(syncData.data.tn ?? DEFAULT_THEME); + updateOrder([syncData.data.o ?? [...DEFAULT_SECTION_ORDER], []], true); + } + }; + + state.pushSyncData = async (forceUpdate?: boolean) => { + const { t, s, ...rest } = + await chrome.storage.local.get(); + + if (forceUpdate || s) { + const newSyncData: SyncStorageData = { + data: rest, + ts: Date.now(), + }; + void chrome.storage.sync.set(newSyncData); + updateSync(newSyncData); + } + }; + + refs.push.onclick = () => state.pushSyncData!(true); + + refs.clear.onclick = () => { + void chrome.storage.sync.clear(); + updateSync({}); + }; + }; + + void supportsSync().then((canSync) => { + if (canSync) { + void chrome.storage.sync.get().then(updateSync); + // TODO: Listen for sync data changes? + // chrome.storage.sync.onChanged.addListener((changes) => {}); + } else { + refs.feedback2.nodeValue = 'Not signed in or sync not supported'; + } + }); + return root; }; diff --git a/src/sw.ts b/src/sw.ts index 1504971f..c3f4ed00 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,13 +1,13 @@ /// -import type { ThemesData, UserStorageData } from './types'; +import type { SyncStorageData, ThemesData, UserStorageData } from './types'; // On install or subsequent update, preload the user's chosen theme into storage // eslint-disable-next-line @typescript-eslint/no-misused-promises chrome.runtime.onInstalled.addListener(async () => { const [themes, settings] = await Promise.all([ fetch('themes.json').then((res) => res.json() as Promise), - chrome.storage.local.get(), + chrome.storage.local.get('tn'), ]); // TODO: Remove once most users have updated. @@ -30,3 +30,34 @@ chrome.runtime.onInstalled.addListener(async () => { // }); // } }); + +/* ********************************** */ +// Experimental sync settings feature // +/* ********************************** */ + +// On profile startup, pull remote user settings; local <- sync +chrome.runtime.onStartup.addListener(() => { + void chrome.storage.local + .get(['s', 'tn']) + .then((settings) => { + // Only when sync is enabled + if (!settings.s) return; + + void chrome.storage.sync.get().then((remote) => { + if (remote.data) { + if (remote.data.tn === settings.tn) { + void chrome.storage.local.set(remote.data); + } else { + void fetch('themes.json') + .then((res) => res.json() as Promise) + .then((themes) => { + void chrome.storage.local.set({ + t: themes[settings.tn ?? 'auto'], + ...remote.data, + }); + }); + } + } + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 777bc4bf..b1c6ad7b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,4 +22,13 @@ export interface UserStorageData { b?: boolean; /** Sections order user preference. */ o?: SectionOrderItem[]; + /** Settings sync enabled user preference. */ + s?: boolean; +} + +export interface SyncStorageData { + /** User settings data. */ + data?: Omit; + /** Timestamp of the last sync.set operation. */ + ts?: number; } diff --git a/test/e2e/settings.spec.ts b/test/e2e/settings.spec.ts index ad4c5481..94249642 100644 --- a/test/e2e/settings.spec.ts +++ b/test/e2e/settings.spec.ts @@ -4,17 +4,18 @@ import { expect, test } from './fixtures'; test('settings page', async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/settings.html`); - // FIXME: Better assertions + // FIXME: Better assertions. await expect(page).toHaveTitle('New Tab'); await expect(page).toHaveURL(`chrome-extension://${extensionId}/settings.html`); // didn't redirect const labels = await page.locator('label').all(); - expect(labels).toHaveLength(4); await expect(labels[0]).toHaveText('Theme'); await expect(labels[1]).toHaveText('Show bookmarks bar'); await expect(labels[2]).toHaveText('Sections'); await expect(labels[3]).toHaveText('Reset'); + await expect(labels[4]).toHaveText('Automatically sync settings'); + expect(labels).toHaveLength(5); }); test.skip('matches screenshot', async ({ page, extensionId }) => { @@ -32,4 +33,4 @@ test('has no console calls or unhandled errors', async ({ page, extensionId }) = expect(consoleMessages).toHaveLength(0); }); -// TODO: Test it makes no external requests (other than fetch themes) +// TODO: Test it makes no external requests (other than fetch themes). diff --git a/test/setup.ts b/test/setup.ts index cb50b1a5..73c8144c 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -23,8 +23,15 @@ function setupMocks(): void { history: { search: noopAsyncArr, }, - // @ts-expect-error - partial mock runtime: { + // @ts-expect-error - partial mock + onInstalled: { + addListener: noop, + }, + // @ts-expect-error - partial mock + onStartup: { + addListener: noop, + }, openOptionsPage: noopAsync, }, // @ts-expect-error - partial mock @@ -38,6 +45,13 @@ function setupMocks(): void { remove: noopAsync, set: noopAsync, }, + // @ts-expect-error - partial mock + sync: { + clear: noopAsync, + get: noopAsyncObj, + remove: noopAsync, + set: noopAsync, + }, }, tabs: { // @ts-expect-error - partial mock diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 6621dbe6..c1058cd6 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -19,8 +19,8 @@ describe('dist files', () => { ['newtab.js', 'text/javascript;charset=utf-8', 4000, 6000], ['settings.css', 'text/css;charset=utf-8', 1000, 1500], ['settings.html', 'text/html;charset=utf-8', 150, 200], - ['settings.js', 'text/javascript;charset=utf-8', 4000, 6000], - ['sw.js', 'text/javascript;charset=utf-8', 150, 300], + ['settings.js', 'text/javascript;charset=utf-8', 6000, 8000], + ['sw.js', 'text/javascript;charset=utf-8', 400, 600], ['themes.json', 'application/json;charset=utf-8'], ]; diff --git a/test/unit/newtab.test.ts b/test/unit/newtab.test.ts index 610574f2..dcddf331 100644 --- a/test/unit/newtab.test.ts +++ b/test/unit/newtab.test.ts @@ -6,7 +6,7 @@ import { reset } from '../setup'; // Completely reset DOM and global state between tests afterEach(reset); -const MODULE_PATH = import.meta.resolveSync('../../dist/newtab.js'); +const MODULE_PATH = Bun.resolveSync('./dist/newtab.js', '.'); async function load() { // Workaround for hack in src/BookmarkBar.ts that waits for styles to be loaded @@ -26,8 +26,8 @@ test('renders entire newtab app', async () => { expect(document.body.querySelector('#m')).toBeTruthy(); expect(document.body.querySelector('#d')).toBeTruthy(); - // TODO: More/better assertions - // TODO: Check all section headings exist; a h2 with text 'Open Tabs' x5 + // TODO: More and better assertions. + // TODO: Check all section headings exist; a h2 with text 'Open Tabs' x5. }); test('does not call any console methods', async () => { diff --git a/test/unit/settings.test.ts b/test/unit/settings.test.ts index f6967e87..7a8a9754 100644 --- a/test/unit/settings.test.ts +++ b/test/unit/settings.test.ts @@ -6,7 +6,7 @@ import { reset } from '../setup'; // Completely reset DOM and global state between tests afterEach(reset); -const MODULE_PATH = import.meta.resolveSync('../../dist/settings.js'); +const MODULE_PATH = Bun.resolveSync('./dist/settings.js', '.'); const themes = Bun.file('dist/themes.json'); async function load() { @@ -32,7 +32,7 @@ test('renders entire settings app', async () => { await load(); expect(document.body.innerHTML.length).toBeGreaterThan(600); - // TODO: More/better assertions + // TODO: More and better assertions. }); test('does not call any console methods', async () => { diff --git a/test/unit/sw.test.ts b/test/unit/sw.test.ts index f7a0bb59..93c54c07 100644 --- a/test/unit/sw.test.ts +++ b/test/unit/sw.test.ts @@ -1,2 +1,81 @@ -// TODO -$console.warn('Not implemented'); +import { afterEach, expect, test } from 'bun:test'; +import { spyOn } from 'bun:test'; +import { describe } from 'bun:test'; +import { performanceSpy } from '@maxmilton/test-utils/spy'; +import { reset } from '../setup'; + +// Completely reset DOM and global state between tests +afterEach(reset); + +const MODULE_PATH = Bun.resolveSync('./dist/sw.js', '.'); + +async function load(noMocks?: boolean) { + if (!noMocks) { + const originalFetch = global.fetch; + + // @ts-expect-error - monkey patching fetch for testing + global.fetch = (input, init) => { + if (input === 'themes.json') { + return Promise.resolve({ + json: () => Promise.resolve({}), + }); + } + return originalFetch(input, init); + }; + + chrome.runtime.onInstalled.addListener = (callback) => { + callback({ reason: 'install' as chrome.runtime.OnInstalledReason.INSTALL }); + }; + chrome.runtime.onStartup.addListener = (callback) => { + callback(); + }; + } + + Loader.registry.delete(MODULE_PATH); + await import(MODULE_PATH); + await happyDOM.waitUntilComplete(); +} + +test('does not call any console methods', async () => { + expect.assertions(1); + await load(); + expect(happyDOM.virtualConsolePrinter.read()).toBeArrayOfSize(0); +}); + +test('does not call any performance methods', async () => { + expect.hasAssertions(); // variable number of assertions + const check = performanceSpy(); + await load(); + check(); +}); + +test('does not call fetch() except themes.json', async () => { + expect.assertions(1); + const spy = spyOn(global, 'fetch'); + await load(); + expect(spy).not.toHaveBeenCalled(); +}); + +describe('onInstalled', () => { + test('has listener', async () => { + expect.assertions(1); + const spy = spyOn(chrome.runtime.onInstalled, 'addListener'); + await load(true); + expect(spy).toHaveBeenCalledTimes(1); + }); + + // TODO: Test with various settings. + // TODO: More and better assertions. +}); + +describe('onStartup', () => { + test('has listener', async () => { + expect.assertions(1); + const spy = spyOn(chrome.runtime.onStartup, 'addListener'); + await load(true); + expect(spy).toHaveBeenCalledTimes(1); + }); + + // TODO: Test with various settings, especially sync enabled/disabled. + // TODO: More and better assertions. +});