Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_draftkeep.mjs

5.5 KiB, 1 run

created by r2519314175:379, 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// A DRAFT BELONGS TO THE DIAMOND, NOT TO THE FACE IT WAS TYPED ON.
2//
3// Reported by the owner, 2026-08-21, using the app: he began typing a reply to a
4// daimon on the chat face, switched to the crystal face, and the text was gone.
5//
6// `moveComposerTo` guards the SAVE on the conversation actually changing --
7// "re-entering the conversation already on screen must not read the box back into
8// itself" -- and then clobbers the box unconditionally on the next line:
9//
10// if (current && current !== next) current._draft = chatInput.value;
11// chatInput.value = (next && next._draft) || '';
12//
13// A Diamond's two faces share ONE conversation record, so a face switch has
14// `current === next`: the save is skipped and the overwrite is not. The box is
15// replaced by a stale `_draft`, empty for anyone who had not switched away before.
16// The comment above the guard describes the intended behaviour correctly; the code
17// implements half of it.
18//
19// The last two checks exist so the fix cannot be "never touch the box": moving
20// between two DIFFERENT conversations must still put each draft back where it
21// belongs, which is the feature the guard was protecting.
22import { open, shot, errors } from './harness.mjs';
23
24const BREAK = (() => {
25 const i = process.argv.indexOf('--break');
26 return i > 0 ? process.argv[i + 1] : '';
27})();
28
29let failed = 0;
30const check = (ok, what, detail) => {
31 console.log(` ${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ' — ' + detail : ''}`);
32 if (!ok) failed++;
33};
34
35const s = await open({ name: 'draftkeep' });
36try {
37 const p = s.page;
38
39 // The break restores the shipped behaviour, so the checks below can be shown to
40 // redden on it. It patches the live function rather than a copy of the rule.
41 if (BREAK === 'wipe') {
42 await p.evaluate(() => {
43 const el = document.getElementById('chat-input');
44 window.__wipe = () => { el.value = ''; };
45 });
46 }
47
48 const type = async (text) => {
49 await p.fill('#chat-input', text);
50 await p.waitForTimeout(80);
51 };
52 const composer = () => p.$eval('#chat-input', (el) => el.value);
53 const face = async (which) => {
54 await p.$eval('#dview-' + which, (el) => el.click());
55 await p.waitForTimeout(400);
56 if (BREAK === 'wipe') await p.evaluate(() => window.__wipe && window.__wipe());
57 };
58
59 // The rail's add button opens a naming dialog; the Diamond does not exist until
60 // it is confirmed, and `#chat-input` is not on screen before that.
61 await p.$eval('#new-diamond-btn', (el) => el.click());
62 await p.waitForTimeout(800);
63 await p.fill('.dlg-card input', 'DraftKeep');
64 await p.$eval('.dlg-card .dlg-ok', (el) => el.click());
65 await p.waitForTimeout(1200);
66
67 // ── 1. chat face → crystal face ──────────────────────────────
68 await face('chat');
69 await type('half a sentence I have not finished');
70 await face('crystal');
71 check((await composer()) === 'half a sentence I have not finished',
72 'TEXT TYPED ON THE CHAT FACE SURVIVES A SWITCH TO THE CRYSTAL FACE',
73 JSON.stringify(await composer()));
74
75 // ── 2. and back again ────────────────────────────────────────
76 await face('chat');
77 check((await composer()) === 'half a sentence I have not finished',
78 'and it is still there on the way back',
79 JSON.stringify(await composer()));
80
81 // ── 3. crystal face → chat face ──────────────────────────────
82 await face('crystal');
83 await type('a different unfinished thought');
84 await face('chat');
85 check((await composer()) === 'a different unfinished thought',
86 'TEXT TYPED ON THE CRYSTAL FACE SURVIVES A SWITCH TO THE CHAT FACE',
87 JSON.stringify(await composer()));
88
89 // ── 4. a real conversation change still swaps the draft ──────
90 //
91 // The guard exists for this. A fix that simply stopped touching the box would
92 // carry one conversation's draft into another, which is a worse bug than the
93 // one being fixed: it would put words the user wrote to a daimon into the box
94 // aimed at something else.
95 await p.$eval('#new-session-btn', (el) => el.click());
96 await p.waitForTimeout(700);
97 check((await composer()) === '',
98 'A DIFFERENT CONVERSATION DOES NOT INHERIT THE DIAMOND\'S DRAFT',
99 JSON.stringify(await composer()));
100
101 const rail = await p.$$('#diamond-list > *');
102 if (rail.length) {
103 await rail[0].evaluate((el) => el.click());
104 await p.waitForTimeout(700);
105 check((await composer()) === 'a different unfinished thought',
106 'and going back to the Diamond finds ITS draft, not the chat\'s',
107 JSON.stringify(await composer()));
108 } else {
109 check(false, 'and going back to the Diamond finds ITS draft', 'no tile to click');
110 }
111
112 await shot(s, 'draftkeep');
113 // A 502 on /api/ means there is no gateway behind this world -- that is what a
114 // bad-gateway status IS -- and drafts are a page concern that needs none, so this
115 // verifier runs either way. While a gate holds :9002 there is no gateway to have.
116 // The exemption is narrow on purpose: only 502, only /api/. A 4xx or a 500 from
117 // the same path is a gateway answering badly rather than a gateway absent, and
118 // still fails, as does any other console error.
119 const errs = errors(s).filter((e) =>
120 !(/\b502\b/.test(e) && /\/api\//.test(e)));
121 check(errs.length === 0, 'nothing threw, gateway-absent 502s aside',
122 errs.slice(0, 2).join(' | '));
123} finally {
124 await s.close();
125}
126console.log(failed ? `\ndraftkeep: ${failed} check(s) FAILED` : '\ndraftkeep: all checks passed');
127process.exit(failed ? 1 : 0);