1// Tests of static/js/store.js (window.ermiteStore): the localStorage client,
2// evaluated alone against a fake storage. Run with
3// node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { readFileSync } from 'node:fs';
6import { test } from 'node:test';
7
8const src = readFileSync(new URL('../static/js/store.js', import.meta.url), 'utf8');
9
10// load evaluates the file on a page whose storage is the given object (a
11// Map-backed one by default) and answers the store and the "storage" listeners.
12function load(storage = fakeStorage()) {
13 const listeners = new Set();
14 const g = globalThis;
15 g.window = g;
16 if (storage === 'missing') delete g.localStorage;
17 else g.localStorage = storage;
18 g.addEventListener = (type, f) => { if (type === 'storage') listeners.add(f); };
19 g.removeEventListener = (type, f) => { if (type === 'storage') listeners.delete(f); };
20 new Function(src)();
21 const emit = (e) => [...listeners].forEach((f) => f({ storageArea: g.localStorage, ...e }));
22 return { store: g.ermiteStore, listeners, emit };
23}
24
25function fakeStorage() {
26 const m = new Map();
27 return {
28 m,
29 getItem: (k) => (m.has(k) ? m.get(k) : null),
30 setItem: (k, v) => { m.set(k, String(v)); },
31 removeItem: (k) => { m.delete(k); },
32 };
33}
34
35const refusing = () => ({
36 getItem: () => { throw new Error('denied'); },
37 setItem: () => { throw new Error('quota'); },
38 removeItem: () => { throw new Error('denied'); },
39});
40
41test('get answers the stored value, else the fallback (null by default)', () => {
42 const st = fakeStorage();
43 const { store } = load(st);
44 assert.equal(store.get('k'), null);
45 assert.equal(store.get('k', 'x'), 'x');
46 st.m.set('k', 'v');
47 assert.equal(store.get('k', 'x'), 'v');
48 st.m.set('k', '');
49 assert.equal(store.get('k', 'x'), '', 'an empty string is a value');
50});
51
52test('set stores strings; null and undefined remove; remove forgets', () => {
53 const st = fakeStorage();
54 const { store } = load(st);
55 store.set('k', 'v');
56 assert.equal(st.m.get('k'), 'v');
57 store.set('n', 3);
58 assert.equal(st.m.get('n'), '3');
59 store.set('k', null);
60 assert.equal(st.m.has('k'), false);
61 store.set('k', 'v');
62 store.set('k', undefined);
63 assert.equal(st.m.has('k'), false);
64 store.remove('n');
65 assert.equal(store.get('n'), null);
66 store.remove('nothing'); // no error
67});
68
69test('without storage nothing throws and the value holds for the page', () => {
70 const { store } = load('missing');
71 assert.equal(store.get('k', 'x'), 'x');
72 store.set('k', 'v');
73 assert.equal(store.get('k', 'x'), 'v');
74 store.remove('k');
75 assert.equal(store.get('k', 'x'), 'x');
76});
77
78test('a storage that refuses reads and writes does not throw either', () => {
79 const { store } = load(refusing());
80 assert.equal(store.get('k', 'x'), 'x');
81 store.set('k', 'v');
82 assert.equal(store.get('k', 'x'), 'v', 'held in memory');
83 store.remove('k');
84 assert.equal(store.get('k', 'x'), 'x', 'removed for the page');
85});
86
87test('a quota error on write keeps the value for the page; a later write that works wins', () => {
88 const st = fakeStorage();
89 const { store } = load(st);
90 let full = true;
91 st.setItem = (k, v) => { if (full) throw new Error('quota'); st.m.set(k, v); };
92 store.set('k', 'a');
93 assert.equal(store.get('k'), 'a');
94 assert.equal(st.m.has('k'), false);
95 full = false;
96 store.set('k', 'b');
97 assert.equal(st.m.get('k'), 'b');
98 st.m.set('k', 'c'); // changed elsewhere: the held value is gone
99 assert.equal(store.get('k'), 'c');
100});
101
102test('a getter of localStorage that throws (sandboxed frame) is survived', () => {
103 const { store } = load('missing');
104 Object.defineProperty(globalThis, 'localStorage', { get() { throw new Error('SecurityError'); }, configurable: true });
105 try {
106 assert.equal(store.get('k', 'x'), 'x');
107 store.set('k', 'v');
108 assert.equal(store.get('k'), 'v');
109 } finally {
110 delete globalThis.localStorage;
111 }
112});
113
114test('watch calls back with the new value of its key only, and stops when told', () => {
115 const { store, emit, listeners } = load();
116 const seen = [];
117 const stop = store.watch('k', (v) => seen.push(v));
118 emit({ key: 'other', newValue: 'z' });
119 emit({ key: 'k', newValue: 'v' });
120 emit({ key: 'k', newValue: null });
121 emit({ key: null, newValue: null }); // the other tab cleared everything
122 assert.deepEqual(seen, ['v', null, null]);
123 stop();
124 assert.equal(listeners.size, 0);
125 emit({ key: 'k', newValue: 'w' });
126 assert.deepEqual(seen, ['v', null, null]);
127});
128
129test('watch ignores the events of another storage area (sessionStorage)', () => {
130 const { store, emit } = load();
131 const seen = [];
132 store.watch('k', (v) => seen.push(v));
133 emit({ key: 'k', newValue: 'v', storageArea: {} });
134 assert.deepEqual(seen, []);
135});
136
137test('an event from another tab replaces a value held for this page', () => {
138 const { store, emit } = load(refusing());
139 store.set('k', 'mine');
140 store.watch('k', () => {});
141 emit({ key: 'k', newValue: 'theirs', storageArea: undefined });
142 assert.equal(store.get('k'), null, 'the held value is dropped; storage is the truth');
143});
144
145test('pref answers only values of its list, else the fallback', () => {
146 const st = fakeStorage();
147 const { store } = load(st);
148 const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
149 assert.equal(p.get(), 'a', 'nothing stored');
150 p.set('b');
151 assert.equal(st.m.get('layout'), 'b');
152 assert.equal(p.get(), 'b');
153 p.set('zzz'); // not allowed: ignored
154 assert.equal(p.get(), 'b');
155 st.m.set('layout', 'old-value');
156 assert.equal(p.get(), 'a', 'an invalid stored value falls back');
157});
158
159test('pref works with the empty string as a value (keys-off)', () => {
160 const st = fakeStorage();
161 const { store } = load(st);
162 const p = store.pref('keys-off', { values: ['1', ''], fallback: '' });
163 assert.equal(p.get(), '');
164 p.set('1');
165 assert.equal(st.m.get('keys-off'), '1');
166 p.set('');
167 assert.equal(st.m.get('keys-off'), '');
168 assert.equal(p.get(), '');
169});
170
171test('pref.watch gives validated values', () => {
172 const { store, emit } = load();
173 const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
174 const seen = [];
175 const stop = p.watch((v) => seen.push(v));
176 emit({ key: 'layout', newValue: 'b' });
177 emit({ key: 'layout', newValue: 'junk' });
178 emit({ key: 'layout', newValue: null });
179 assert.deepEqual(seen, ['b', 'a', 'a']);
180 stop();
181 emit({ key: 'layout', newValue: 'b' });
182 assert.equal(seen.length, 3);
183});