Skip to content

Commit f9ef899

Browse files
Add Node test baseline with browser shim coverage
Configure npm test to preload a minimal browser shim so Node tests can import extension modules outside a browser runtime. Add focused unit tests for SSE handling, API flow helpers, model and config conversion, and utility guards to establish baseline regression coverage. Align shim behavior with Chrome API contracts by supporting callback overloads for tabs.sendMessage and runtime.sendMessage, and by using own-property checks in storage.local.get to avoid prototype-key collisions. Add dedicated shim contract tests for object-default lookups and prototype-like keys such as toString and constructor.
1 parent aabd810 commit f9ef899

11 files changed

Lines changed: 1114 additions & 0 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dev": "node build.mjs --development",
77
"analyze": "node build.mjs --analyze",
88
"lint": "eslint --ext .js,.mjs,.jsx .",
9+
"test": "node --import ./tests/setup/browser-shim.mjs --test",
910
"lint:fix": "eslint --ext .js,.mjs,.jsx . --fix",
1011
"pretty": "prettier --write ./**/*.{js,mjs,jsx,json,css,scss}",
1112
"stage": "run-script-os",

tests/setup/browser-shim.mjs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Minimal browser-extension API shim for Node test runtime.
2+
// Scope is intentionally small: only APIs used by current unit tests are mocked.
3+
const createEvent = () => {
4+
const listeners = new Set()
5+
return {
6+
addListener(listener) {
7+
listeners.add(listener)
8+
},
9+
removeListener(listener) {
10+
listeners.delete(listener)
11+
},
12+
hasListener(listener) {
13+
return listeners.has(listener)
14+
},
15+
_trigger(...args) {
16+
for (const listener of Array.from(listeners)) {
17+
listener(...args)
18+
}
19+
},
20+
_clear() {
21+
listeners.clear()
22+
},
23+
_size() {
24+
return listeners.size
25+
},
26+
}
27+
}
28+
29+
const runtimeOnMessage = createEvent()
30+
const commandsOnCommand = createEvent()
31+
32+
const createStorageState = (values = {}) => Object.assign(Object.create(null), values)
33+
let storageState = createStorageState()
34+
35+
const resolveStorageGet = (keys) => {
36+
if (keys === null || keys === undefined) return { ...storageState }
37+
38+
if (typeof keys === 'string') {
39+
return Object.hasOwn(storageState, keys) ? { [keys]: storageState[keys] } : {}
40+
}
41+
42+
if (Array.isArray(keys)) {
43+
const result = {}
44+
for (const key of keys) {
45+
if (Object.hasOwn(storageState, key)) result[key] = storageState[key]
46+
}
47+
return result
48+
}
49+
50+
if (typeof keys === 'object') {
51+
const result = {}
52+
for (const [key, defaultValue] of Object.entries(keys)) {
53+
if (Object.hasOwn(storageState, key)) result[key] = storageState[key]
54+
else result[key] = defaultValue
55+
}
56+
return result
57+
}
58+
59+
return {}
60+
}
61+
62+
const storageLocal = {
63+
get(keys, callback) {
64+
const result = resolveStorageGet(keys)
65+
if (typeof callback === 'function') {
66+
queueMicrotask(() => callback(result))
67+
return
68+
}
69+
return Promise.resolve(result)
70+
},
71+
set(items, callback) {
72+
Object.assign(storageState, items ?? {})
73+
if (typeof callback === 'function') {
74+
queueMicrotask(() => callback())
75+
return
76+
}
77+
return Promise.resolve()
78+
},
79+
clear(callback) {
80+
storageState = createStorageState()
81+
if (typeof callback === 'function') {
82+
queueMicrotask(() => callback())
83+
return
84+
}
85+
return Promise.resolve()
86+
},
87+
}
88+
89+
const tabs = {
90+
query(_queryInfo, callback) {
91+
const result = []
92+
if (typeof callback === 'function') {
93+
queueMicrotask(() => callback(result))
94+
return
95+
}
96+
return Promise.resolve(result)
97+
},
98+
sendMessage(_tabId, _message, optionsOrCallback, callback) {
99+
const cb =
100+
typeof optionsOrCallback === 'function'
101+
? optionsOrCallback
102+
: typeof callback === 'function'
103+
? callback
104+
: null
105+
if (cb) {
106+
queueMicrotask(() => cb())
107+
return
108+
}
109+
return Promise.resolve()
110+
},
111+
}
112+
113+
const windows = {
114+
create(_createData, callback) {
115+
const result = { id: 1 }
116+
if (typeof callback === 'function') {
117+
queueMicrotask(() => callback(result))
118+
return
119+
}
120+
return Promise.resolve(result)
121+
},
122+
update(_windowId, _updateInfo, callback) {
123+
const result = { id: 1 }
124+
if (typeof callback === 'function') {
125+
queueMicrotask(() => callback(result))
126+
return
127+
}
128+
return Promise.resolve(result)
129+
},
130+
}
131+
132+
const runtime = {
133+
id: 'test-extension-id',
134+
onMessage: runtimeOnMessage,
135+
sendMessage(_message, optionsOrCallback, callback) {
136+
const cb =
137+
typeof optionsOrCallback === 'function'
138+
? optionsOrCallback
139+
: typeof callback === 'function'
140+
? callback
141+
: null
142+
if (cb) {
143+
queueMicrotask(() => cb())
144+
return
145+
}
146+
return Promise.resolve()
147+
},
148+
getURL(path) {
149+
return `chrome-extension://test/${path}`
150+
},
151+
}
152+
153+
const chromeShim = {
154+
runtime,
155+
storage: {
156+
local: storageLocal,
157+
},
158+
tabs,
159+
windows,
160+
commands: {
161+
onCommand: commandsOnCommand,
162+
},
163+
}
164+
165+
if (!globalThis.navigator) {
166+
globalThis.navigator = {
167+
language: 'en-US',
168+
userAgent: 'Mozilla/5.0 (X11; Linux x86_64)',
169+
}
170+
}
171+
172+
if (!globalThis.chrome) {
173+
globalThis.chrome = chromeShim
174+
} else {
175+
globalThis.chrome.runtime ||= runtime
176+
globalThis.chrome.storage ||= { local: storageLocal }
177+
globalThis.chrome.storage.local ||= storageLocal
178+
globalThis.chrome.tabs ||= tabs
179+
globalThis.chrome.windows ||= windows
180+
globalThis.chrome.commands ||= { onCommand: commandsOnCommand }
181+
}
182+
183+
globalThis.__TEST_BROWSER_SHIM__ = {
184+
setStorage(values) {
185+
Object.assign(storageState, values)
186+
},
187+
replaceStorage(values) {
188+
storageState = createStorageState(values)
189+
},
190+
clearStorage() {
191+
storageState = createStorageState()
192+
},
193+
getStorage() {
194+
return { ...storageState }
195+
},
196+
resetEvents() {
197+
runtimeOnMessage._clear()
198+
commandsOnCommand._clear()
199+
},
200+
}

tests/unit/helpers/port.mjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export function createFakePort() {
2+
const onMessageListeners = new Set()
3+
const onDisconnectListeners = new Set()
4+
const postedMessages = []
5+
6+
return {
7+
postedMessages,
8+
onMessage: {
9+
addListener(listener) {
10+
onMessageListeners.add(listener)
11+
},
12+
removeListener(listener) {
13+
onMessageListeners.delete(listener)
14+
},
15+
},
16+
onDisconnect: {
17+
addListener(listener) {
18+
onDisconnectListeners.add(listener)
19+
},
20+
removeListener(listener) {
21+
onDisconnectListeners.delete(listener)
22+
},
23+
},
24+
postMessage(message) {
25+
postedMessages.push(message)
26+
},
27+
emitMessage(message) {
28+
for (const listener of Array.from(onMessageListeners)) {
29+
listener(message)
30+
}
31+
},
32+
emitDisconnect() {
33+
for (const listener of Array.from(onDisconnectListeners)) {
34+
listener()
35+
}
36+
},
37+
listenerCounts() {
38+
return {
39+
onMessage: onMessageListeners.size,
40+
onDisconnect: onDisconnectListeners.size,
41+
}
42+
},
43+
}
44+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const encoder = new TextEncoder()
2+
3+
export function createMockSseResponse(chunks, options = {}) {
4+
const { ok = true, status = 200, statusText = 'OK', json = async () => ({}) } = options
5+
6+
return {
7+
ok,
8+
status,
9+
statusText,
10+
json,
11+
body: {
12+
getReader() {
13+
let index = 0
14+
return {
15+
async read() {
16+
if (index >= chunks.length) {
17+
return { done: true, value: undefined }
18+
}
19+
20+
const value = encoder.encode(chunks[index])
21+
index += 1
22+
return { done: false, value }
23+
},
24+
}
25+
},
26+
},
27+
}
28+
}

0 commit comments

Comments
 (0)