mirror of
https://github.com/HeyPuter/builder.git
synced 2026-11-13 20:12:42 -10:00
If the module Settings is built on never finished loading, a /?settings= link waited fifteen seconds and then did nothing. It now opens Settings anyway, which reports that it isn't ready the same way its toolbar button does. Adds tests that run the link handler.
612 lines
34 KiB
JavaScript
612 lines
34 KiB
JavaScript
import fs from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
// ---- Regression guard: a project on disk is never lost from the sidebar ------
|
|
// The sidebar lists what chat-history/chat-list.json names; a project is its
|
|
// chat file (chat-history/<id>.json) plus its app directory. Three ways a
|
|
// project's entry was lost while its files stayed intact, and the explicit way
|
|
// back for the ones already lost:
|
|
//
|
|
// 1. A rebuild from the chat files skipped any file it could not read at that
|
|
// moment, then saved the result. The list was valid from then on, so it
|
|
// was never rebuilt again.
|
|
// 2. A save whose read of the list FAILED wrote this tab's list over it,
|
|
// whatever it held.
|
|
// 3. A project created while a list write was in flight was recorded as
|
|
// synced, and the next save took it for one deleted in another tab.
|
|
// (Covered by test-chat-list-save-race.mjs, whose fake disk can be read.)
|
|
// 4. Settings → Projects (findMissingProjects / restoreMissingProjects) puts
|
|
// back entries for chat files nothing lists. It must only ever add list
|
|
// entries: every chat file byte-identical afterwards, every entry that was
|
|
// listed still listed, a confirmed copy of the old list kept.
|
|
//
|
|
// Runs the real chat-list code from app.js in a VM over a fake puter.fs.
|
|
|
|
let failures = 0;
|
|
function check(name, cond, detail) {
|
|
if (cond) console.log('ok - ' + name);
|
|
else { console.error('FAIL - ' + name + (detail ? '\n ' + detail : '')); failures++; }
|
|
}
|
|
|
|
const read = (p) => fs.readFileSync(new URL(p, import.meta.url), 'utf8');
|
|
const app = read('../src/js/app.js');
|
|
const helpers = read('../src/js/helpers.js');
|
|
const mcpUi = read('../src/js/mcp-ui.js');
|
|
const LOCK = helpers.slice(helpers.indexOf('const _fileLocks = new Map()'), helpers.indexOf('// Wait for the writes currently in flight'));
|
|
const start = app.indexOf('let savedChats = [];');
|
|
const end = app.indexOf('// Merge just the public-site (Publish) fields');
|
|
if (start < 0 || end < 0 || !LOCK) throw new Error('could not extract the chat-list code');
|
|
const CODE = LOCK + '\n' + app.slice(start, end) + `
|
|
globalThis.__list = () => savedChats;
|
|
globalThis.__setList = (l) => { savedChats = l; };
|
|
globalThis.__setCurrent = (id) => { currentChatId = id; };
|
|
globalThis.__loaded = () => chatListLoaded;
|
|
globalThis.__adopt = (id, chat) => adoptChatFileIntoEntry(id, chat);
|
|
globalThis.__verify = (id) => verifyChatListEntry(id);
|
|
globalThis.__verifyAll = () => verifyChatListEntries();
|
|
globalThis.__merge = (a, b, c, d) => mergeChatLists(a, b, c, d);
|
|
`;
|
|
|
|
const LIST = 'chat-history/chat-list.json';
|
|
const fileOf = (id) => `chat-history/${id}.json`;
|
|
|
|
// A fake disk. `failRead(path, n)` makes the next n reads of a path fail the
|
|
// way a dropped request does (Infinity: until cleared); `failWrite` likewise.
|
|
function world() {
|
|
const disk = new Map();
|
|
const readFailures = new Map();
|
|
const writeFailures = new Map();
|
|
const writes = [];
|
|
const clock = { waited: 0, reads: 0 };
|
|
const nf = (p) => Object.assign(new Error('not found ' + p), { code: 'subject_does_not_exist' });
|
|
const take = (map, p) => {
|
|
const left = map.get(p) || 0;
|
|
if (left <= 0) return false;
|
|
map.set(p, left - 1);
|
|
return true;
|
|
};
|
|
const puterFs = {
|
|
read: async (p) => {
|
|
await null;
|
|
clock.reads++;
|
|
if (take(readFailures, p) || (readFailures.get('*') && p !== LIST)) throw new Error('network error');
|
|
if (!disk.has(p)) throw nf(p);
|
|
const d = disk.get(p);
|
|
return { text: async () => d };
|
|
},
|
|
write: async (p, d) => {
|
|
await null;
|
|
if (clock.hold && p === LIST) await clock.hold; // a slow list write
|
|
if (take(writeFailures, p) || take(writeFailures, '*')) throw new Error('network error');
|
|
writes.push(p);
|
|
disk.set(p, d);
|
|
},
|
|
stat: async () => ({}), mkdir: async () => {},
|
|
// Only the direct children, directories included, as Puter lists them.
|
|
readdir: async (p) => {
|
|
if (take(readFailures, 'readdir')) throw new Error('network error');
|
|
const names = new Map();
|
|
for (const k of disk.keys()) {
|
|
if (!k.startsWith(p + '/')) continue;
|
|
const rest = k.slice(p.length + 1);
|
|
const slash = rest.indexOf('/');
|
|
if (slash < 0) names.set(rest, { name: rest, is_dir: false, modified: 1767225600 });
|
|
else names.set(rest.slice(0, slash), { name: rest.slice(0, slash), is_dir: true });
|
|
}
|
|
return [...names.values()];
|
|
},
|
|
delete: async (p) => { disk.delete(p); },
|
|
};
|
|
function tab() {
|
|
const deleted = new Set();
|
|
const toasts = [];
|
|
const sb = {
|
|
window: { showToast: (m) => toasts.push(m), currentPreviewUrl: null }, puter: { fs: puterFs },
|
|
console: { log() {}, warn() {}, error() {} },
|
|
// Retries wait on a timer; the test doesn't, but adds the waits up.
|
|
setTimeout: (fn, ms) => { clock.waited += ms || 0; setImmediate(fn); },
|
|
generateChatId: () => 'boot', generateChatTitle: (h) => 'title:' + ((h.find((m) => m.role === 'user') || {}).content || ''),
|
|
_deletedChatIds: deleted, _aiProjectTitles: new Map(), _suggestionsByChat: new Map(),
|
|
setUrlChat() {}, updateChatHistorySidebar() {}, updateDocumentTitle() {},
|
|
};
|
|
vm.createContext(sb);
|
|
vm.runInContext(CODE, sb);
|
|
sb.deleted = deleted;
|
|
sb.toasts = toasts;
|
|
return sb;
|
|
}
|
|
const list = () => JSON.parse(disk.get(LIST));
|
|
return {
|
|
disk, tab, list, writes, clock,
|
|
failRead: (p, n = 1) => readFailures.set(p, n),
|
|
failWrite: (p, n = 1) => writeFailures.set(p, n),
|
|
};
|
|
}
|
|
const ids = (l) => l.map((c) => c.id).sort().join(',');
|
|
const order = (l) => l.map((c) => c.id).join(',');
|
|
const chatFile = (id, extra) => JSON.stringify(Object.assign({
|
|
id, title: 'Title ' + id, history: [{ role: 'user', content: id }], lastModified: '2026-01-0' + (id.charCodeAt(id.length - 1) % 9 + 1),
|
|
}, extra));
|
|
const entryOf = (id, extra) => Object.assign({ id, title: 'Title ' + id, customTitle: false, aiTitled: false, pinned: false }, extra);
|
|
const seed = (w, listIds, fileIds) => {
|
|
if (listIds) w.disk.set(LIST, JSON.stringify(listIds.map((id) => entryOf(id))));
|
|
for (const id of fileIds || listIds || []) w.disk.set(fileOf(id), chatFile(id));
|
|
};
|
|
// Every chat file, to compare byte for byte.
|
|
const chatFiles = (w) => JSON.stringify([...w.disk].filter(([k]) => k !== LIST && !k.includes('/list-backups/')).sort());
|
|
const settle = async () => { for (let i = 0; i < 20; i++) await new Promise(setImmediate); };
|
|
|
|
// ============ 1. A rebuild keeps a project whose file it can't read ============
|
|
{
|
|
const w = world();
|
|
seed(w, null, ['A', 'B', 'C']); // no list: boot rebuilds it from the files
|
|
w.failRead(fileOf('B'), Infinity); // B can't be read during the rebuild
|
|
const before = chatFiles(w);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('rebuild: a file that could not be read is still listed', ids(T.__list()) === 'A,B,C', ids(T.__list()));
|
|
const b = T.__list().find((c) => c.id === 'B');
|
|
check('rebuild: …as an unverified entry', b && b.unverified === true && b.title === 'Untitled project', JSON.stringify(b));
|
|
check('rebuild: …dated from the file, not blank', !!b && b.lastModified === '2026-01-01T00:00:00.000Z', b && b.lastModified);
|
|
check('rebuild: the saved list has it too', ids(w.list()) === 'A,B,C', w.disk.get(LIST));
|
|
check('rebuild: the readable ones are complete entries', T.__list().filter((c) => !c.unverified).every((c) => c.title.startsWith('Title ')));
|
|
check('rebuild: no chat file was written', chatFiles(w) === before);
|
|
|
|
// The file can be read again: the next load completes the entry.
|
|
w.failRead(fileOf('B'), 0);
|
|
const T2 = w.tab();
|
|
await T2.loadSavedChats();
|
|
await settle();
|
|
const b2 = w.list().find((c) => c.id === 'B');
|
|
check('next load: the entry is completed from the file', b2 && !b2.unverified && b2.title === 'Title B', JSON.stringify(b2));
|
|
check('next load: still no chat file written', chatFiles(w) === before);
|
|
}
|
|
{
|
|
// One dropped request is retried, not treated as an unreadable project.
|
|
const w = world();
|
|
seed(w, null, ['A', 'B']);
|
|
w.failRead(fileOf('B'), 2);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
const b = T.__list().find((c) => c.id === 'B');
|
|
check('rebuild: a read that fails twice then works gives a complete entry', b && !b.unverified && b.title === 'Title B', JSON.stringify(b));
|
|
}
|
|
{
|
|
// A dropped read of a healthy list no longer sends boot down the rebuild.
|
|
const w = world();
|
|
seed(w, ['A', 'B']);
|
|
w.disk.set(LIST, JSON.stringify([entryOf('B', { title: 'Renamed', customTitle: true }), entryOf('A')]));
|
|
w.failRead(LIST, 2);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('load: the list read is retried', order(T.__list()) === 'B,A' && T.__list()[0].title === 'Renamed', JSON.stringify(T.__list()));
|
|
}
|
|
{
|
|
// A file that is valid JSON but not a saved chat is not a project; a file
|
|
// gone since the listing is not one either; a directory is skipped.
|
|
const w = world();
|
|
seed(w, null, ['A']);
|
|
w.disk.set('chat-history/notes.json', JSON.stringify({ hello: 1 }));
|
|
w.disk.set('chat-history/list-backups/chat-list-old.json', JSON.stringify([entryOf('GONE')]));
|
|
w.disk.set('chat-history/odd.json/inner.json', chatFile('odd'));
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('rebuild: only real chat files are listed (no stray JSON, backups or directories)', ids(T.__list()) === 'A', ids(T.__list()));
|
|
}
|
|
{
|
|
// A truncated file can't be told from a response cut short: keep it listed.
|
|
const w = world();
|
|
seed(w, null, ['A', 'B']);
|
|
w.disk.set(fileOf('B'), chatFile('B').slice(0, 20));
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('rebuild: a file that does not parse is listed, unverified', T.__list().find((c) => c.id === 'B')?.unverified === true, JSON.stringify(T.__list()));
|
|
check('verify: …and reported corrupt, the entry left as it is', await T.__verify('B') === 'corrupt' && T.__list().find((c) => c.id === 'B').unverified === true);
|
|
}
|
|
|
|
// ---- reads failing across the board: don't retry every file -------------------
|
|
{
|
|
// Offline or throttled, each retried file costs a second and two more
|
|
// requests. Over hundreds of chat files that held boot for minutes.
|
|
const w = world();
|
|
const all = Array.from({ length: 60 }, (_, i) => 'P' + String(i).padStart(2, '0'));
|
|
seed(w, null, all);
|
|
w.failRead('*', Infinity); // every chat file read fails
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('rebuild, every read failing: every project is still listed', T.__list().length === 60 && T.__list().every((c) => c.unverified), String(T.__list().length));
|
|
check('rebuild, every read failing: the retries stop after the first files', w.clock.waited <= 2000, w.clock.waited + ' ms waited');
|
|
check('rebuild, every read failing: about one request per file, not three', w.clock.reads <= 60 + 8, w.clock.reads + ' reads');
|
|
|
|
// The next load completes entries in the background; while reads still
|
|
// fail it stops at the first, and tries again at the load after.
|
|
const T2 = w.tab();
|
|
w.clock.waited = 0; w.clock.reads = 0;
|
|
await T2.loadSavedChats();
|
|
await settle(); await settle();
|
|
check('completion, reads still failing: gives up after the first entry', w.clock.waited <= 1000 && w.clock.reads <= 6, w.clock.waited + ' ms, ' + w.clock.reads + ' reads');
|
|
w.failRead('*', 0);
|
|
await T2.__verifyAll();
|
|
check('completion, reads working again: every entry is completed', w.list().length === 60 && w.list().every((c) => !c.unverified && c.title.startsWith('Title ')));
|
|
}
|
|
{
|
|
// A file that doesn't parse isn't a failing connection: retries go on.
|
|
const w = world();
|
|
seed(w, null, ['A', 'B', 'C', 'D', 'E']);
|
|
for (const id of ['A', 'B', 'C']) w.disk.set(fileOf(id), '{"id":');
|
|
w.failRead(fileOf('E'), 2); // dropped twice, then fine
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
const e = T.__list().find((c) => c.id === 'E');
|
|
check('rebuild: corrupt files don\'t use up the retries a dropped request needs', e && !e.unverified, JSON.stringify(e));
|
|
}
|
|
|
|
// ---- completing an unverified entry -----------------------------------------
|
|
{
|
|
const w = world();
|
|
seed(w, null, ['A', 'B']);
|
|
w.disk.set(fileOf('B'), chatFile('B', { customTitle: true, title: 'Mine', pinned: true, publishedUrl: 'https://b.puter.site/' }));
|
|
w.failRead(fileOf('B'), Infinity);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('verify: unreadable while the file can\'t be read, entry untouched',
|
|
await T.__verify('B') === 'unreadable' && T.__list().find((c) => c.id === 'B').unverified === true);
|
|
w.failRead(fileOf('B'), 0);
|
|
check('verify: verified once it can', await T.__verify('B') === 'verified');
|
|
const b = T.__list().find((c) => c.id === 'B');
|
|
check('verify: the entry carries the file\'s title, pin and published address',
|
|
!b.unverified && b.title === 'Mine' && b.customTitle === true && b.pinned === true && b.publishedUrl === 'https://b.puter.site/', JSON.stringify(b));
|
|
check('verify: an entry that is already complete is left alone', await T.__verify('A') === 'verified');
|
|
}
|
|
{
|
|
// What opening the project does (loadChat → adoptChatFileIntoEntry).
|
|
const w = world();
|
|
seed(w, null, ['A', 'B']);
|
|
w.failRead(fileOf('B'), Infinity);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
// The user renamed and pinned the stand-in entry meanwhile.
|
|
const b = T.__list().find((c) => c.id === 'B');
|
|
b.title = 'My name'; b.customTitle = true; b.pinned = true;
|
|
const changed = T.__adopt('B', JSON.parse(chatFile('B', { previewUrl: 'https://p.puter.site/' })));
|
|
const after = T.__list().find((c) => c.id === 'B');
|
|
check('adopt: completes the entry and keeps a rename and pin made on the stand-in',
|
|
changed === true && !after.unverified && after.title === 'My name' && after.customTitle === true && after.pinned === true
|
|
&& after.previewUrl === 'https://p.puter.site/', JSON.stringify(after));
|
|
check('adopt: a complete entry is never replaced', T.__adopt('A', JSON.parse(chatFile('A', { title: 'Other' }))) === false
|
|
&& T.__list().find((c) => c.id === 'A').title === 'Title A');
|
|
check('adopt: a file for another project is refused', T.__adopt('A', JSON.parse(chatFile('B'))) === false);
|
|
check('adopt: position in the list is kept', order(T.__list()) === order(w.list()), order(T.__list()));
|
|
}
|
|
{
|
|
// A stand-in entry never replaces the complete one already on disk.
|
|
const w = world();
|
|
const T = w.tab();
|
|
const mine = [Object.assign(entryOf('B'), { title: 'Untitled project', unverified: true }), entryOf('A')];
|
|
const disk = [entryOf('A'), entryOf('B', { title: 'Real', pinned: true })];
|
|
const merged = T.__merge(mine, disk, new Map(), new Set());
|
|
const b = merged.find((c) => c.id === 'B');
|
|
check('merge: the complete entry on disk wins over a stand-in', b.title === 'Real' && !b.unverified && merged.length === 2, JSON.stringify(merged));
|
|
}
|
|
|
|
{
|
|
// Boot could read neither the list nor the directory, so the list loads at
|
|
// the first save instead. A project made meanwhile is in memory, complete;
|
|
// if that late rebuild can't read its file, the stand-in must not replace
|
|
// the entry this session already has (a save would take the stand-in's
|
|
// title and pin for the project's own).
|
|
const w = world();
|
|
seed(w, null, ['A']);
|
|
w.failRead(LIST, Infinity);
|
|
w.failRead('readdir', 1);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('late load: boot left the list unloaded', T.__loaded() === false);
|
|
w.failRead(fileOf('N'), Infinity);
|
|
T.__setCurrent('N');
|
|
await T.saveCurrentChat({ currentChatId: 'N', chatHistory: [{ role: 'user', content: 'N' }, { role: 'assistant', content: 'x' }], interrupted: true });
|
|
const n = T.__list().find((c) => c.id === 'N');
|
|
check('late load: the list is loaded by the save', T.__loaded() === true && ids(T.__list()) === 'A,N', ids(T.__list()));
|
|
check('late load: the project made this session keeps its own entry', !!n && !n.unverified && n.title === 'title:N', JSON.stringify(n));
|
|
}
|
|
|
|
// ============ 2. A save never writes over a list it couldn't read ==============
|
|
{
|
|
const w = world();
|
|
seed(w, ['A', 'B']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
// Another tab adds a project; this tab's read of the list then fails.
|
|
w.disk.set(fileOf('N'), chatFile('N'));
|
|
w.disk.set(LIST, JSON.stringify([entryOf('N'), entryOf('A'), entryOf('B')]));
|
|
const onDisk = w.disk.get(LIST);
|
|
T.__list().find((c) => c.id === 'A').pinned = true; // this tab's own change
|
|
w.failRead(LIST, Infinity);
|
|
await T.saveChatList();
|
|
check('save: the list is not written while it can\'t be read', w.disk.get(LIST) === onDisk, w.disk.get(LIST));
|
|
check('save: …the user is told', T.toasts.length === 1, JSON.stringify(T.toasts));
|
|
check('save: …and this tab keeps its change in memory', T.__list().find((c) => c.id === 'A').pinned === true);
|
|
// The read works again: the next save merges, losing neither side.
|
|
w.failRead(LIST, 0);
|
|
await T.saveChatList();
|
|
check('save: the next save keeps the other tab\'s project and this tab\'s change',
|
|
ids(w.list()) === 'A,B,N' && w.list().find((c) => c.id === 'A').pinned === true, w.disk.get(LIST));
|
|
}
|
|
{
|
|
const w = world();
|
|
seed(w, ['A', 'B']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
w.failRead(LIST, 2); // two dropped requests, then fine
|
|
T.__list().find((c) => c.id === 'A').pinned = true;
|
|
await T.saveChatList();
|
|
check('save: a read that fails twice then works still saves', w.list().find((c) => c.id === 'A').pinned === true && T.toasts.length === 0);
|
|
}
|
|
{
|
|
const w = world();
|
|
seed(w, ['A', 'B']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
w.disk.delete(LIST); // the list is gone: nothing to protect
|
|
await T.saveChatList();
|
|
check('save: a missing list is written, as before', ids(w.list()) === 'A,B');
|
|
w.disk.set(LIST, '[{"id":"A"'); // corrupt: this tab's list replaces it
|
|
await T.saveChatList();
|
|
check('save: a corrupt list is replaced, as before', ids(w.list()) === 'A,B');
|
|
}
|
|
{
|
|
// The whole of gap 1 + 2 together: the list read fails at boot, one chat
|
|
// file can't be read during the rebuild, and the list read fails again at
|
|
// the save. The intact list on disk must survive.
|
|
const w = world();
|
|
seed(w, ['A', 'B', 'C']);
|
|
const onDisk = w.disk.get(LIST);
|
|
w.failRead(LIST, Infinity);
|
|
w.failRead(fileOf('B'), Infinity);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
check('flaky boot: every project is shown', ids(T.__list()) === 'A,B,C', ids(T.__list()));
|
|
check('flaky boot: the intact list on disk is not overwritten', w.disk.get(LIST) === onDisk);
|
|
w.failRead(LIST, 0); w.failRead(fileOf('B'), 0);
|
|
await T.saveChatList();
|
|
const b = w.list().find((c) => c.id === 'B');
|
|
check('flaky boot: once readable, the saved list has all three, B complete', ids(w.list()) === 'A,B,C' && b && !b.unverified, w.disk.get(LIST));
|
|
}
|
|
|
|
// ============ 3. Changes made while a list write is in flight ==================
|
|
{
|
|
const w = world();
|
|
seed(w, ['A', 'B']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
let release;
|
|
w.clock.hold = new Promise((r) => { release = r; });
|
|
T.__list().find((c) => c.id === 'A').pinned = true;
|
|
const first = T.saveChatList(); // in flight until released
|
|
await settle();
|
|
// Meanwhile: a rename, and a new project.
|
|
const b = T.__list().find((c) => c.id === 'B'); b.title = 'Renamed'; b.customTitle = true;
|
|
T.__list().unshift(entryOf('N'));
|
|
const second = T.saveChatList();
|
|
await settle();
|
|
w.clock.hold = null; release();
|
|
await Promise.all([first, second]);
|
|
await settle();
|
|
check('in flight: a project created during the write is kept by the next save', ids(w.list()) === 'A,B,N' && ids(T.__list()) === 'A,B,N', w.disk.get(LIST));
|
|
check('in flight: a rename made during the write is not reverted', w.list().find((c) => c.id === 'B').title === 'Renamed', w.disk.get(LIST));
|
|
check('in flight: the change the first write carried is there too', w.list().find((c) => c.id === 'A').pinned === true);
|
|
}
|
|
|
|
// ============ 4. Settings → Projects: restore missing projects =================
|
|
{
|
|
const w = world();
|
|
seed(w, ['A', 'B'], ['A', 'B', 'LOST1', 'LOST2']);
|
|
w.disk.set(fileOf('LOST1'), chatFile('LOST1', { lastModified: '2026-02-01', pinned: true, publishedUrl: 'https://l.puter.site/' }));
|
|
w.disk.set(fileOf('LOST2'), chatFile('LOST2', { lastModified: '2026-03-01' }));
|
|
w.disk.set('chat-history/notes.json', JSON.stringify({ hello: 1 })); // not a chat
|
|
w.disk.set('chat-history/broken.json', '{"id":"broken","hist'); // doesn't parse
|
|
w.disk.set('chat-history/copy.json', chatFile('A')); // another project's id
|
|
w.disk.set('chat-history/nohistory.json', JSON.stringify({ id: 'nohistory' })); // nothing to open
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
const everything = JSON.stringify([...w.disk].sort());
|
|
const writesBefore = w.writes.length;
|
|
|
|
const found = await T.findMissingProjects();
|
|
check('find: the chat files nothing lists, newest first', order(found.missing) === 'LOST2,LOST1', order(found.missing));
|
|
check('find: files that are not an openable project are reported, not offered',
|
|
[...found.unreadable].sort().join() === 'broken.json,copy.json,nohistory.json,notes.json', found.unreadable.join());
|
|
check('find: entries carry what the file knows', found.missing[1].pinned === true && found.missing[1].publishedUrl === 'https://l.puter.site/'
|
|
&& found.missing[1].title === 'Title LOST1');
|
|
check('find: changes nothing on disk', JSON.stringify([...w.disk].sort()) === everything && w.writes.length === writesBefore);
|
|
check('find: changes nothing in memory', order(T.__list()) === 'A,B');
|
|
|
|
const files = chatFiles(w);
|
|
const oldList = w.disk.get(LIST);
|
|
const done = await T.restoreMissingProjects(['LOST1']);
|
|
check('restore: only the project asked for', order(done.restored) === 'LOST1' && ids(w.list()) === 'A,B,LOST1', w.disk.get(LIST));
|
|
check('restore: confirmed on disk', done.saved === true);
|
|
check('restore: it goes first, the rest keep their order', order(w.list()) === 'LOST1,A,B', order(w.list()));
|
|
check('restore: every entry that was listed is unchanged',
|
|
JSON.stringify(w.list().slice(1)) === JSON.stringify(JSON.parse(oldList)));
|
|
check('restore: every chat file is byte-identical', chatFiles(w) === files);
|
|
check('restore: a copy of the old list was kept', !!done.backupPath && w.disk.get(done.backupPath) === oldList, String(done.backupPath));
|
|
check('restore: only the list and its copy were written',
|
|
w.writes.slice(writesBefore).every((p) => p === LIST || p === done.backupPath), w.writes.slice(writesBefore).join());
|
|
check('restore: shown in this tab', order(T.__list()) === 'LOST1,A,B');
|
|
|
|
const again = await T.restoreMissingProjects(['LOST1']);
|
|
check('restore: asking again adds nothing', again.restored.length === 0 && order(w.list()) === 'LOST1,A,B' && again.backupPath === null);
|
|
|
|
// The copies are never taken for projects: by a rebuild, or by another look.
|
|
const T2 = w.tab();
|
|
w.disk.delete(LIST);
|
|
await T2.loadSavedChats();
|
|
check('restore: the kept copy is not mistaken for a project by a rebuild',
|
|
!T2.__list().some((c) => /chat-list/.test(c.id)) && T2.__list().every((c) => !c.unverified || c.id === 'broken'), ids(T2.__list()));
|
|
}
|
|
{
|
|
// Nothing is changed when the copy of the list can't be saved.
|
|
const w = world();
|
|
seed(w, ['A'], ['A', 'LOST']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
const everything = JSON.stringify([...w.disk].sort());
|
|
w.failWrite('*', 1);
|
|
let threw = false;
|
|
try { await T.restoreMissingProjects(['LOST']); } catch (e) { threw = true; }
|
|
check('restore: a copy that can\'t be saved stops it', threw);
|
|
check('restore: …with nothing changed on disk or in memory', JSON.stringify([...w.disk].sort()) === everything && order(T.__list()) === 'A');
|
|
}
|
|
{
|
|
// Nothing is changed when the list can't be read.
|
|
const w = world();
|
|
seed(w, ['A'], ['A', 'LOST']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
const everything = JSON.stringify([...w.disk].sort());
|
|
w.failRead(LIST, Infinity);
|
|
let threwFind = false, threwRestore = false;
|
|
try { await T.findMissingProjects(); } catch (e) { threwFind = true; }
|
|
try { await T.restoreMissingProjects(['LOST']); } catch (e) { threwRestore = true; }
|
|
check('restore: an unreadable list stops both the look and the restore', threwFind && threwRestore);
|
|
check('restore: …with nothing changed', JSON.stringify([...w.disk].sort()) === everything && order(T.__list()) === 'A');
|
|
}
|
|
{
|
|
// The list write fails after the copy: reported, nothing lost, saved later.
|
|
const w = world();
|
|
seed(w, ['A'], ['A', 'LOST']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
const oldList = w.disk.get(LIST);
|
|
w.failWrite(LIST, 1);
|
|
const done = await T.restoreMissingProjects(['LOST']);
|
|
check('restore: a failed list write is reported as not saved', done.saved === false && done.restored.length === 1);
|
|
check('restore: …the list on disk is as it was', w.disk.get(LIST) === oldList);
|
|
check('restore: …and the project is shown meanwhile', order(T.__list()) === 'LOST,A');
|
|
await T.saveChatList();
|
|
check('restore: …and saved by the next save', order(w.list()) === 'LOST,A');
|
|
}
|
|
{
|
|
// Never brings back a project deleted in this session, or one another tab
|
|
// has just created (it is on disk, only not in this tab's memory yet).
|
|
const w = world();
|
|
seed(w, ['A'], ['A', 'DELETED', 'NEW']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
T.deleted.add('DELETED');
|
|
w.disk.set(LIST, JSON.stringify([entryOf('NEW'), entryOf('A')]));
|
|
const found = await T.findMissingProjects();
|
|
check('find: a project deleted this session is not offered', !found.missing.some((c) => c.id === 'DELETED'));
|
|
check('find: a project another tab just listed is not offered', !found.missing.some((c) => c.id === 'NEW') && found.missing.length === 0);
|
|
}
|
|
{
|
|
// Before the list has loaded there is nothing to compare against.
|
|
const w = world();
|
|
seed(w, ['A'], ['A', 'LOST']);
|
|
const T = w.tab();
|
|
let threw = false;
|
|
try { await T.findMissingProjects(); } catch (e) { threw = true; }
|
|
check('find: refuses before the project list has loaded', threw && T.__loaded() === false);
|
|
}
|
|
{
|
|
// No list at all (boot rebuilt it): nothing to copy, nothing missing.
|
|
const w = world();
|
|
seed(w, null, ['A', 'B']);
|
|
const T = w.tab();
|
|
await T.loadSavedChats();
|
|
const found = await T.findMissingProjects();
|
|
check('find: after a rebuild nothing is missing', found.missing.length === 0 && found.unreadable.length === 0);
|
|
}
|
|
|
|
// ============ Wiring ==========================================================
|
|
{
|
|
const del = app.slice(app.indexOf('async function deleteChat(chatId) {'), app.indexOf('const subsToDelete = new Set();'));
|
|
const guard = del.indexOf("if (savedChats.find(c => c.id === chatId)?.unverified) {");
|
|
check('delete: an unverified entry is completed from its file before anything is removed',
|
|
guard >= 0 && guard < del.indexOf('_deletedChatIds.add(chatId);') && del.includes("if (state === 'unreadable') {"));
|
|
const load = app.slice(app.indexOf('async function loadChat(chatId'), app.indexOf('const superseded = () => {'));
|
|
check('open: the entry is completed from the file before the project can be saved',
|
|
/JSON\.parse\(await puter\.fs\.read\(`chat-history\/\$\{chatId\}\.json`\)[\s\S]{0,400}if \(adoptChatFileIntoEntry\(chatId, chat\)\)/.test(load));
|
|
check('save: what is recorded as synced is the list as it was written, taken before the write is awaited',
|
|
/const written = JSON\.stringify\(savedChats\);\s*const synced = chatListSyncedMap\(savedChats\);\s*await puter\.fs\.write\(CHAT_LIST_PATH, written\);\s*_chatListSynced = synced;/.test(app));
|
|
const restore = app.slice(app.indexOf('async function findMissingProjects() {'), app.indexOf('window.restoreMissingProjects = restoreMissingProjects;'));
|
|
check('restore: the code never deletes, moves or copies over anything',
|
|
restore.length > 0 && !/puter\.fs\.(delete|move|rename|copy)\b/.test(restore) && !/puter\.(hosting|workers)\b/.test(restore));
|
|
check('restore: its only writes are the copy of the list and saveChatList',
|
|
(restore.match(/puter\.fs\.write\(/g) || []).length === 1 && restore.includes('await puter.fs.write(backupPath, raw,')
|
|
&& (restore.match(/saveChatList\(\)/g) || []).length === 1);
|
|
|
|
check('Settings has a Projects tab',
|
|
mcpUi.includes('role="tab" id="settings-tab-projects" aria-controls="settings-panel-projects" data-tab="projects">Projects</button>')
|
|
&& mcpUi.includes('role="tabpanel" id="settings-panel-projects" aria-labelledby="settings-tab-projects" data-tab="projects">'));
|
|
check('…that openSettings can ask for', mcpUi.includes("tab === 'projects'"));
|
|
check('…which looks only when asked, and restores only when asked again',
|
|
mcpUi.includes("$scan.on('click'") && mcpUi.includes('await window.findMissingProjects()')
|
|
&& mcpUi.includes("$go.on('click'") && mcpUi.includes('await window.restoreMissingProjects(ids)')
|
|
&& (mcpUi.match(/restoreMissingProjects\(/g) || []).length === 1 && (mcpUi.match(/findMissingProjects\(/g) || []).length === 1);
|
|
// Disabling the button just pressed drops keyboard focus out of the dialog.
|
|
const panel = mcpUi.slice(mcpUi.indexOf("const $restore = $overlay.find('.settings-restore-result');"), mcpUi.indexOf("window.addEventListener('mcp-connections-changed', render);"));
|
|
check('…whose waiting buttons are aria-disabled, never disabled, so focus stays in the dialog',
|
|
panel.length > 0 && !/\.prop\('disabled'/.test(panel) && panel.includes(".attr('aria-disabled', 'true')"));
|
|
check('…and focus goes back to the Look button when the results it was in are rebuilt',
|
|
/const hadFocus = \$restore\[0\]\.contains\(document\.activeElement\);\s*\$restore\.empty\(\);\s*if \(hadFocus\) \$scan\.trigger\('focus'\);/.test(panel)
|
|
&& !/\$restore\.empty\(\);[\s\S]*\$restore\.empty\(\);/.test(panel));
|
|
check('…and shows titles as text', mcpUi.includes(".text(chat.title || 'Untitled project')"));
|
|
// Four tabs are wider than a phone: the strip scrolls, it doesn't push the
|
|
// last tab (this one) and the dialog off to the right.
|
|
const css = read('../src/css/styles.css');
|
|
const strip = (css.match(/\n\.settings-tabs \{[^}]*\}/) || [''])[0];
|
|
const tabRule = (css.match(/\n\.mcp-modal \.settings-tab \{[^}]*\}/) || [''])[0];
|
|
check('the tab strip scrolls sideways on a narrow screen', /overflow-x: auto;/.test(strip) && /flex: none;/.test(tabRule) && /white-space: nowrap;/.test(tabRule));
|
|
check('…and the selected tab is brought into view, also once the dialog is shown',
|
|
(mcpUi.match(/revealSelectedTab\(\);/g) || []).length === 2 && /addClass\('open'\);[^\n]*revealSelectedTab\(\);/.test(mcpUi));
|
|
const link = app.slice(app.indexOf('function openSettingsFromLink() {'), app.indexOf('// Reveal the cloaked UI only once'));
|
|
check('/?settings=projects opens it, and does nothing else',
|
|
link.includes("url.searchParams.get('settings')") && link.includes("'projects'") && link.includes('window.openSettings(tab)')
|
|
&& !/MissingProjects|saveChatList|puter\.fs/.test(link));
|
|
check('…the param is taken out of the address bar', link.includes("url.searchParams.delete('settings')") && link.includes('history.replaceState('));
|
|
}
|
|
|
|
// ============ The link: /?settings=projects ====================================
|
|
{
|
|
const fnSrc = app.slice(app.indexOf('function openSettingsFromLink() {'), app.indexOf('// Reveal the cloaked UI only once'));
|
|
// Runs the real function against a fake address bar; timers run at once.
|
|
function visit(href, { managerAfter = 0 } = {}) {
|
|
const opened = [];
|
|
const replaced = [];
|
|
let ticks = 0;
|
|
const window = {
|
|
location: { href },
|
|
mcpManager: managerAfter === 0 ? {} : undefined,
|
|
openSettings: (tab) => opened.push(tab),
|
|
};
|
|
const history = { state: { chatId: 'X' }, replaceState: (state, _t, url) => replaced.push([state, url]) };
|
|
const queue = [];
|
|
const setTimeout = (fn) => queue.push(fn);
|
|
new Function('window', 'history', 'setTimeout', 'URL', fnSrc + '\nopenSettingsFromLink();')(window, history, setTimeout, URL);
|
|
while (queue.length && ticks < 500) {
|
|
ticks++;
|
|
if (managerAfter > 0 && ticks === managerAfter) window.mcpManager = {};
|
|
queue.shift()();
|
|
}
|
|
return { opened, replaced, ticks };
|
|
}
|
|
let v = visit('https://builder.example/?settings=projects');
|
|
check('link: opens Settings on the Projects tab', v.opened.join() === 'projects', v.opened.join());
|
|
check('link: takes the param out of the address bar, keeping the history state', v.replaced.length === 1 && v.replaced[0][1] === '/' && v.replaced[0][0].chatId === 'X', JSON.stringify(v.replaced));
|
|
v = visit('https://builder.example/?p=abc&settings=projects&x=1#frag');
|
|
check('link: leaves the rest of the address alone', v.replaced[0][1] === '/?p=abc&x=1#frag', v.replaced[0] && v.replaced[0][1]);
|
|
v = visit('https://builder.example/?p=abc');
|
|
check('link: no param, nothing happens', v.opened.length === 0 && v.replaced.length === 0);
|
|
v = visit('https://builder.example/?settings=nonsense');
|
|
check('link: an unknown tab opens nothing, and is still removed', v.opened.length === 0 && v.replaced.length === 1);
|
|
v = visit('https://builder.example/?settings=projects', { managerAfter: 5 });
|
|
check('link: waits for Settings to finish loading', v.opened.join() === 'projects', v.opened.join());
|
|
v = visit('https://builder.example/?settings=projects', { managerAfter: -1 });
|
|
check('link: if Settings never loads, says so once instead of doing nothing', v.opened.join() === 'projects' && v.ticks < 200, v.opened.join() + ' after ' + v.ticks);
|
|
}
|
|
|
|
if (failures) { console.error(`\n${failures} check(s) FAILED`); process.exit(1); }
|
|
console.log('\nAll missing-projects checks passed.');
|