Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_drafts.mjs

12.5 KiB, 1 run

created by r2519314175:381, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_drafts.mjs — what somebody is half-way through typing survives a
2// reload, and does NOT thereby become a send queue.
3//
4// The owner reported one case and stated the general rule: "A screen refresh
5// deletes text entered but waiting and not processed into Social > Notes. I
6// would expect all live text input to persist, stored temporarily in the
7// browser so it returns after a refresh."
8//
9// He is right, and the box is the one place in an app where the user rather
10// than the app holds the only copy. `www/js/drafts.js` keeps it.
11//
12// ── WHY THIS FILE IS HALF ABOUT WHAT MUST *NOT* HAPPEN ───────────────
13//
14// `dev/IMPROVE_CONTRACT.md` §4: "A note leaves this device only when a person
15// presses Send on that one note, and what leaves is exactly the characters that
16// are on the screen at that moment. Nothing about a note is queued, retried,
17// batched, synced or kept for later sending."
18//
19// Held text LOOKS like the thing that forbids, so the difference is asserted
20// rather than argued. What is forbidden is SENDING WITHOUT A PRESS. A draft is
21// not waiting to go; it is waiting to be looked at, by the person who typed it,
22// on the device they typed it on. So every check that the words come back is
23// paired with a check that they went NOWHERE — counted at the network, over the
24// whole session, whatever the address, exactly as verify_improve.mjs counts.
25//
26// FIVE PROPERTIES:
27//
28// 1. THE NOTE BOX SURVIVES A RELOAD, and the words are the same characters.
29// 2. AND NOT ONE REQUEST IN THE WHOLE SESSION CARRIED THEM. Not on the type,
30// not on the reload, not afterwards. This is the check that says a draft is
31// not a queue, and it is blind to the address on purpose.
32// 3. A BOX THAT WAS EMPTIED IS EMPTY AFTER A RELOAD. A draft that outlived the
33// act of clearing it would put words back that somebody had deleted, which
34// is worse than losing them: it looks like the app arguing.
35// 4. THE CHAT COMPOSER SURVIVES ONE TOO, and a draft belongs to ITS OWN
36// conversation — switching away and back finds the same words, and a
37// different chat does not inherit them.
38// 5. NOTHING REACHES THE SYNC PARCEL. A draft that crossed to a phone would be
39// text moving without a press, which is the thing §4 forbids, arriving by
40// the side door.
41//
42// PROVED RED. `--unbuilt` serves the page with `DaimondDrafts` removed before
43// anything binds to it, which is what the app did before this existed.
44//
45// ITS REACH IS THREE CHECKS AND THEY ARE NAMED, because a break whose reach is
46// not written down is a break whose reach is not known: the note box coming
47// back, the composer coming back, and the second conversation keeping its own.
48// Those three are one property asserted over three surfaces, which is the point
49// of asserting it three times — the owner said "all live text input", not "the
50// note box".
51//
52// The other eleven STAY GREEN under it, and that is what makes them evidence
53// rather than echo. They are the properties the old app also satisfied: nothing
54// was sent, an emptied box stayed empty, no draft reached the parcel. A break
55// that reddened everything would prove nothing about which check caught what.
56// It also proves the call sites tolerate the module's absence — the run
57// finishes, and the unhandled-error check is one of the eleven.
58//
59// eval "$(bash dev/world.sh 7 --env)"
60// node dev/verify_drafts.mjs
61// node dev/verify_drafts.mjs --unbuilt # expected to FAIL, exactly 3 checks
62//
63// Needs dev/serve.mjs and node. No gateway: nothing here reaches one, which is
64// itself the point.
65import { open, shot, scratch, errors, newChat } from './harness.mjs';
66
67const UNBUILT = process.argv.includes('--unbuilt');
68
69let bad = 0, n = 0;
70const check = (ok, what, detail) => {
71 n++;
72 if (!ok) bad++;
73 console.log(` ${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ' — ' + detail : ''}`);
74};
75
76// The markers. Distinctive enough that a substring search over every request
77// body and URL in the session cannot match anything else.
78const NOTE = 'the rail forgot where it was quokka-draft-note';
79const CHAT = 'ask the daimon about quokka-draft-chat';
80const SECOND = 'a different conversation quokka-draft-other';
81
82const profile = scratch('pw', 'drafts-' + process.pid);
83const s = await open({
84 name: 'drafts' + process.pid,
85 profile,
86 defaults: false,
87 // Every request the page makes, whatever its address, so check 2 can be
88 // asked over the whole session rather than over one route.
89 route: async (page) => {
90 if (UNBUILT) {
91 // The app as it was: the module is served, and then taken away before
92 // anything can bind to it. Serving an EMPTY file would be the same
93 // break; taking the global away after the file has run also proves the
94 // call sites tolerate its absence, which they must.
95 await page.route('**/js/drafts.js', (r) => r.fulfill({
96 status: 200, contentType: 'text/javascript',
97 body: '/* --unbuilt: the app before drafts.js */\n',
98 }));
99 }
100 },
101});
102const p = s.page;
103
104// Everything the page sent, in full. Read from the request rather than from a
105// route handler, so a request that a handler would have had to fulfil is
106// counted the same as one that went out.
107const wire = [];
108p.on('request', (r) => {
109 let body = '';
110 try { body = r.postData() || ''; } catch (e) { body = ''; }
111 wire.push(r.url() + ' ' + body);
112});
113const anywhereOnTheWire = (needle) => wire.filter((w) => w.includes(needle));
114
115const reload = async () => {
116 await p.reload({ waitUntil: 'domcontentloaded' });
117 // The gate is on a fresh load; the profile keeps the identity, so this is an
118 // unlock rather than a create.
119 await p.waitForTimeout(400);
120 const pass = await p.$('#id-pass');
121 if (pass) {
122 await pass.fill('testpass1234');
123 await p.evaluate(() => {
124 const b = document.getElementById('id-primary');
125 if (b) b.click();
126 });
127 }
128 await p.waitForTimeout(1200);
129};
130
131const openSocial = async () => {
132 await p.evaluate(() => {
133 window.DaimondPanels.show('social');
134 if (window.DaimondImprove) window.DaimondImprove.onOpen();
135 });
136 await p.waitForTimeout(600);
137};
138
139console.log(UNBUILT ? 'drafts (UNBUILT — 2 checks are expected to fail)' : 'drafts');
140
141// ── 1 and 2. The note box ────────────────────────────────────────────
142console.log('the note box');
143await openSocial();
144const box = await p.$('#improve-box');
145check(!!box, 'the note box is on screen to type into');
146if (box) {
147 await box.fill(NOTE);
148 // The settle timer, plus a moment. Written as a wait rather than a flush,
149 // because what is being checked is that an ORDINARY pause is enough — a test
150 // that called `flush()` would prove the store works and nothing about whether
151 // the app ever reaches it.
152 await p.waitForTimeout(700);
153 await shot(s, 'drafts-typed');
154
155 const sentWhileTyping = anywhereOnTheWire('quokka-draft-note');
156 check(sentWhileTyping.length === 0,
157 'typing sends nothing — not one request in the session carries the words',
158 sentWhileTyping.length ? sentWhileTyping[0].slice(0, 160) : null);
159
160 await reload();
161 await openSocial();
162 const back = await p.evaluate(() => {
163 const b = document.getElementById('improve-box');
164 return b ? String(b.value || '') : null;
165 });
166 check(back === NOTE, 'and after a reload the words are back, character for character',
167 JSON.stringify(back));
168 await shot(s, 'drafts-restored');
169
170 // THE CHECK THIS FILE EXISTS FOR. The reload is exactly the moment a queue
171 // would flush, so it is asked after it and over the whole session.
172 const sentEver = anywhereOnTheWire('quokka-draft-note');
173 check(sentEver.length === 0,
174 'AND THE RELOAD SENT NOTHING EITHER — a draft is held, never queued',
175 sentEver.length ? `${sentEver.length}, e.g. ${sentEver[0].slice(0, 160)}` : null);
176}
177
178// ── 3. Emptying it means it is empty ─────────────────────────────────
179console.log('emptying it');
180{
181 await p.evaluate(() => {
182 const b = document.getElementById('improve-box');
183 if (!b) return;
184 b.value = '';
185 b.dispatchEvent(new Event('input', { bubbles: true }));
186 });
187 await p.waitForTimeout(700);
188 await reload();
189 await openSocial();
190 const after = await p.evaluate(() => {
191 const b = document.getElementById('improve-box');
192 return b ? String(b.value || '') : null;
193 });
194 check(after === '', 'a box emptied on purpose is still empty after a reload',
195 JSON.stringify(after));
196}
197
198// ── 4. The chat composer ─────────────────────────────────────────────
199console.log('the chat composer');
200{
201 // The Social panel is open over the stage, and the composer is on the stage.
202 // A chat of this run's own, so the draft has a conversation to belong to and
203 // the keying check below has a second one to switch to.
204 await p.evaluate(() => { try { window.DaimondPanels.hide('social'); } catch (e) { /* already */ } });
205 await newChat(s);
206 await p.waitForTimeout(400);
207 const input = await p.$('#chat-input');
208 check(!!input, 'the composer is on screen to type into');
209 if (input) {
210 await input.fill(CHAT);
211 await p.waitForTimeout(700);
212 await reload();
213 const back = await p.evaluate(() => {
214 const b = document.getElementById('chat-input');
215 return b ? String(b.value || '') : null;
216 });
217 check(back === CHAT, 'a half-typed message is back after a reload', JSON.stringify(back));
218 const leaked = anywhereOnTheWire('quokka-draft-chat');
219 check(leaked.length === 0, 'and it went nowhere either',
220 leaked.length ? leaked[0].slice(0, 160) : null);
221
222 // A DRAFT BELONGS TO ITS OWN CONVERSATION. The composer is ONE element
223 // shared by every chat and both faces of every Diamond, so a draft keyed on
224 // the element rather than on the conversation would follow the user into
225 // the next one — which is how a stranger's half-sentence once ended up in a
226 // Diamond that had never seen it.
227 await newChat(s);
228 await p.waitForTimeout(400);
229 const afterSwitch = await p.evaluate(() => document.getElementById('chat-input').value);
230 check(afterSwitch === '', 'a new conversation does not inherit the last one\'s draft',
231 JSON.stringify(afterSwitch));
232 // And it keeps one of its own, which is the other half: a key that was the
233 // same for every conversation would pass the check above only by losing
234 // both drafts.
235 await p.fill('#chat-input', SECOND);
236 await p.waitForTimeout(700);
237 await reload();
238 const secondBack = await p.evaluate(() => document.getElementById('chat-input').value);
239 check(secondBack === SECOND, 'and it keeps its OWN across a reload', JSON.stringify(secondBack));
240 const bothLeaked = anywhereOnTheWire('quokka-draft-other');
241 check(bothLeaked.length === 0, 'and neither of them went anywhere');
242 }
243}
244
245// ── 5. Nothing crosses to another device ─────────────────────────────
246console.log('the sync parcel');
247{
248 const parcel = await p.evaluate(async () => {
249 try {
250 if (!window.DaimondSync || !DaimondSync.parcel) return null;
251 return JSON.stringify(await DaimondSync.parcel());
252 } catch (e) { return 'ERR ' + String(e && e.message); }
253 });
254 if (parcel === null) {
255 check(true, 'the sync module offers no parcel to read here, so nothing can be in it');
256 } else {
257 check(!parcel.includes('quokka-draft'),
258 'no draft is in the sync parcel — a draft that crossed devices would be text moving with no press',
259 parcel.startsWith('ERR') ? parcel : null);
260 check(!parcel.includes('daimond-drafts'),
261 'and the store is not named in it either');
262 }
263}
264
265// ── The page did not fall over doing any of it ───────────────────────
266{
267 // 401 and 502 on `/api/...` are this world with no gateway of its own, which
268 // is deliberate: nothing this file checks reaches one. See dev/world.sh.
269 const errs = errors(s).filter((e) => !/401|502|Failed to fetch|NetworkError/i.test(e));
270 check(errs.length === 0, 'nothing above was reached by way of an unhandled error',
271 errs.slice(0, 2).join(' | '));
272}
273
274await s.close();
275console.log('');
276if (UNBUILT) {
277 // The break must redden exactly the three "the words are back" checks. A break
278 // that reddened everything would say nothing about which check caught what.
279 console.log(bad === 3
280 ? `--unbuilt: ${bad} of ${n} failed, which is the three restore checks and only those`
281 : `--unbuilt: ${bad} of ${n} failed — EXPECTED 3. A break whose reach is not known is not proof.`);
282 process.exit(bad === 3 ? 0 : 1);
283}
284console.log(bad ? `${bad} of ${n} failed` : `all ${n} checks passed`);
285process.exit(bad ? 1 : 0);