1// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2// and the pure core of the form (static/js/issue-form.js). Run with
3// node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { createHash } from 'node:crypto';
6import { test } from 'node:test';
7
8globalThis.window = globalThis;
9globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10await import('../static/js/pow-worker.js');
11await import('../static/js/issue-form.js');
12const P = globalThis.ermitePow;
13const C = window.ermiteIssueForm.core;
14
15// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16// nonce at this difficulty, found by the server's own powHash.
17const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18
19test('leadingZeroBits counts the zero bits at the start', () => {
20 assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21 assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22 assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23 assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24 assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25 assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26 assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27 assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28});
29
30test('b64urlDecode reads the nonce the server sends', () => {
31 assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32 assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33});
34
35test('the hashed bytes are nonce || ":" || decimal solution', () => {
36 const nonce = P.b64urlDecode(VECTOR.nonce);
37 const got = P.input(nonce, 2491);
38 assert.equal(got.length, 16 + 1 + 4);
39 assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40 assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41});
42
43test('the solver finds the very solution of the Go vector', async () => {
44 const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45 assert.equal(sol, VECTOR.solution);
46 const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47 assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48 // and no smaller counter works
49 for (let n = 0; n < Number(sol); n++) {
50 const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51 assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52 }
53});
54
55test('the solver is independent of the batch size and reports progress', async () => {
56 const nonce = P.b64urlDecode(VECTOR.nonce);
57 const seen = [];
58 const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59 assert.equal(sol, VECTOR.solution);
60 assert.deepEqual(seen.slice(0, 2), [100, 200]);
61 const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62 assert.equal(stopped, null, 'stopped by the caller');
63});
64
65test('counterClass: warn from 90 %, over above the maximum', () => {
66 assert.equal(C.counterClass(0, 120), '');
67 assert.equal(C.counterClass(107, 120), '');
68 assert.equal(C.counterClass(108, 120), 'warn');
69 assert.equal(C.counterClass(120, 120), 'warn');
70 assert.equal(C.counterClass(121, 120), 'over');
71 assert.equal(C.counterClass(9000, 10000), 'warn');
72 assert.equal(C.counterClass(8999, 10000), '');
73 assert.equal(C.counterClass(41, 40), 'over');
74});
75
76test('length counts characters, not UTF-16 units', () => {
77 assert.equal(C.length('abc'), 3);
78 assert.equal(C.length('a😀b'), 3);
79 assert.equal(C.length(''), 0);
80});
81
82test('state: the button opens only when solved, waited and not over', () => {
83 const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84 const s = (o) => C.state({ ...base, ...o });
85 assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86 assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87 assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88 assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89 assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90 assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91 assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92 assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93 assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94 assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95 assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96});
97
98test('serverLength counts NFC code points like the server', () => {
99 assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100 assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101 assert.equal(C.counterClass(122, 120), 'over');
102 assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103 assert.equal(C.counterClass(120, 120), 'warn');
104 assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105 assert.equal(C.counterClass(121, 120), 'over');
106});
107
108test('serverLength drops what the server strips and trims', () => {
109 assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110 assert.equal(C.serverLength('title', ' \u202e '), 0);
111 assert.equal(C.serverLength('author', ' me '), 2);
112 assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113 assert.equal(C.serverLength('body', ' a '), 3);
114 assert.equal(C.serverLength('title', 'a\tb'), 2);
115});
116
117test('state keeps the button disabled when a title is empty or a field is over', () => {
118 const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119 assert.equal(C.state(base).disabled, false);
120 assert.equal(C.state({ ...base, overs: true }).disabled, true);
121 assert.equal(C.state({ ...base, empty: true }).disabled, true);
122});
123
124function fakeStorage() {
125 const m = new Map();
126 return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127}
128
129test('drafts are saved, restored once, and scoped to the path', () => {
130 const st = fakeStorage();
131 const k = C.draftKey('/r/issues/new');
132 assert.notEqual(k, C.draftKey('/other/issues/new'));
133 C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134 assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135 assert.equal(st.m.size, 0);
136 assert.equal(C.restoreDraft(st, k), null);
137 st.setItem(k, 'not json');
138 assert.equal(C.restoreDraft(st, k), null);
139});
140
141test('onPageShow reloads only for a bfcache restore', () => {
142 assert.equal(C.onPageShow(true), 'reload');
143 assert.equal(C.onPageShow(false), 'none');
144 assert.equal(C.onPageShow(undefined), 'none');
145});
146
147test('init restores a draft and reloads on a bfcache pageshow', () => {
148 const listeners = {};
149 const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150 const title = mk({ id: 'issue-title', name: 'title', value: '' });
151 const body = mk({ id: 'issue-body', name: 'body', value: '' });
152 const author = mk({ id: 'issue-author', name: 'author', value: '' });
153 const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154 const button = { disabled: false };
155 const solution = { value: '' };
156 const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157 '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158 '#issue-title': title, '#issue-body': body, '#issue-author': author };
159 const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160 querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161 const st = fakeStorage();
162 let reloads = 0;
163 const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164 Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165 addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166 try {
167 C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168 window.ermiteIssueForm.init(form);
169 assert.equal(title.value, 'Hello');
170 assert.equal(outs[0].textContent, '5 / 120');
171 assert.equal(st.m.size, 0);
172 assert.equal(button.disabled, true);
173 listeners.pageshow({ persisted: false });
174 assert.equal(reloads, 0);
175 title.value = 'Edited';
176 listeners.pageshow({ persisted: true });
177 assert.equal(reloads, 1);
178 assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179 assert.equal(button.disabled, true);
180 } finally {
181 for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182 }
183});