v0.32.0 53e25a3

issue-form.test.mjs RawHistoryFolder

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});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close