Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 4 of the sequence): stop concurrent
whole-object saves from losing edits, stop failed saves from reporting clean,
and stop corrupt user data from silently vanishing.
Revisions (core/schema.js, core/store.js):
- Every guide and step carries a monotonic `revision`, bumped on each store
write. Legacy v1 data without the field reads as revision 0 and upgrades on
its next save — no migration pass, no data rewrite.
- saveGuide/saveStep accept { expectedRevision } for compare-and-swap saves;
a mismatch throws RevisionConflictError instead of clobbering. Direct user
edits pass no expectation (the user is the authority); background writers
must pass one.
Stale AI responses (app/text-intel.js):
- generateStepPatch snapshots the step revision before the (slow) model call,
re-reads the step after it, and saves with the original expectedRevision. A
user edit made during generation now surfaces as "the step changed while AI
was generating; nothing was overwritten" — previously the AI response
silently overwrote the newer edit.
Autosave truthfulness (app/renderer/editor.js):
- flushStep/flushGuide cleared the dirty flag BEFORE awaiting the IPC save,
so a rejected save (invoked via a debounce that never handled rejections)
lost the visible dirty state. The flag is now cleared only after a durable
save; failures keep it dirty, surface a persistent saveError in editor
meta, toast the user, and retry on the next edit or explicit save.
- Navigating away from the editor flushes pending debounced saves so the
last edit can never be dropped by a view switch.
Corruption quarantine (core/store.js):
- listGuides/listSteps used to silently skip unreadable entries — a corrupt
guide just vanished from the library. Corrupt guide/step directories are
now moved to library/quarantine (original bytes preserved) and recorded in
a recovery report (store.getRecoveryReport()) for the UI. Empty in-progress
directories are still skipped quietly — absence of guide.json is not
corruption.
Tests: revision increments, stale-CAS rejection with user edit surviving,
CAS success path, guide CAS, v1 no-revision upgrade, guide/step quarantine
with preserved bytes + recovery report, empty-dir non-quarantine, AI
stale-write rejection end-to-end (user edit mid-generation survives) and the
clean-apply path. 240 unit tests pass; startup smoke and sample-artifact
E2E pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
266 lines
9.8 KiB
JavaScript
266 lines
9.8 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { makeTmpDir, rmrf } = require('./helpers');
|
|
const { isLoopbackHost, validateOllamaHost } = require('../../core/text-intel');
|
|
const { TextIntelService } = require('../../app/text-intel');
|
|
const CaptureService = require('../../app/capture');
|
|
|
|
function makeSettings(ai = {}) {
|
|
const data = {
|
|
ai: {
|
|
enabled: true,
|
|
allowRemoteHost: false,
|
|
attachScreenshots: true,
|
|
timeoutMs: 60000,
|
|
maxImageBytes: 12 * 1024 * 1024,
|
|
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b' },
|
|
...ai,
|
|
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b', ...(ai.ollama || {}) },
|
|
},
|
|
};
|
|
return {
|
|
get(key) {
|
|
return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---- loopback policy (pure core) -------------------------------------------
|
|
|
|
test('isLoopbackHost recognizes local addresses and rejects remote ones', () => {
|
|
for (const host of [
|
|
'http://127.0.0.1:11434',
|
|
'127.0.0.1',
|
|
'localhost:11434',
|
|
'http://[::1]:11434',
|
|
'http://127.5.6.7',
|
|
'0.0.0.0',
|
|
]) {
|
|
assert.equal(isLoopbackHost(host), true, `loopback: ${host}`);
|
|
}
|
|
for (const host of [
|
|
'http://10.0.0.5:11434',
|
|
'http://192.168.1.20',
|
|
'https://ollama.example.com',
|
|
'http://8.8.8.8',
|
|
'http://ollama.internal',
|
|
]) {
|
|
assert.equal(isLoopbackHost(host), false, `remote: ${host}`);
|
|
}
|
|
});
|
|
|
|
test('validateOllamaHost blocks remote hosts unless explicitly allowed', () => {
|
|
assert.equal(validateOllamaHost('http://127.0.0.1:11434').ok, true);
|
|
const blocked = validateOllamaHost('http://10.0.0.5:11434');
|
|
assert.equal(blocked.ok, false);
|
|
assert.match(blocked.reason, /remote/i);
|
|
assert.equal(validateOllamaHost('http://10.0.0.5:11434', { allowRemote: true }).ok, true);
|
|
assert.equal(validateOllamaHost('').ok, false);
|
|
assert.equal(validateOllamaHost('ftp://127.0.0.1').ok, false);
|
|
});
|
|
|
|
// ---- host policy enforced by the service -----------------------------------
|
|
|
|
test('AI connection test refuses a remote host without the opt-in', async (t) => {
|
|
const root = makeTmpDir('ai-remote-block');
|
|
t.after(() => rmrf(root));
|
|
let called = false;
|
|
const service = new TextIntelService({
|
|
store: { settingsDir: root },
|
|
settings: makeSettings({ ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' } }),
|
|
dataDir: root,
|
|
fetchImpl: async () => { called = true; return { ok: true, json: async () => ({}) }; },
|
|
});
|
|
const result = await service.testAiConnection();
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.reason, /remote/i);
|
|
assert.equal(called, false, 'must not contact a blocked host at all');
|
|
});
|
|
|
|
test('AI connection test allows a remote host with the opt-in', async (t) => {
|
|
const root = makeTmpDir('ai-remote-allow');
|
|
t.after(() => rmrf(root));
|
|
const service = new TextIntelService({
|
|
store: { settingsDir: root },
|
|
settings: makeSettings({
|
|
allowRemoteHost: true,
|
|
ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' },
|
|
}),
|
|
dataDir: root,
|
|
fetchImpl: async (url) => {
|
|
const pathname = new URL(url).pathname;
|
|
if (pathname === '/api/tags') return { ok: true, json: async () => ({ models: [{ name: 'llama3.2:1b' }] }) };
|
|
if (pathname === '/api/show') return { ok: true, json: async () => ({ capabilities: ['vision'] }) };
|
|
throw new Error(`unexpected ${pathname}`);
|
|
},
|
|
});
|
|
const result = await service.testAiConnection();
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.host, 'http://10.0.0.5:11434');
|
|
});
|
|
|
|
// ---- request timeout / cancellation ----------------------------------------
|
|
|
|
test('a hung endpoint times out instead of hanging forever', async (t) => {
|
|
const root = makeTmpDir('ai-timeout');
|
|
t.after(() => rmrf(root));
|
|
const service = new TextIntelService({
|
|
store: { settingsDir: root },
|
|
settings: makeSettings({ timeoutMs: 40 }),
|
|
dataDir: root,
|
|
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
|
// Never resolves on its own; only the abort signal ends it.
|
|
init.signal.addEventListener('abort', () => {
|
|
const err = new Error('aborted');
|
|
err.name = 'AbortError';
|
|
reject(err);
|
|
});
|
|
}),
|
|
});
|
|
const result = await service.testAiConnection();
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.reason, /timed out|timeout/i);
|
|
});
|
|
|
|
test('cancelInflight aborts an outstanding request', async (t) => {
|
|
const root = makeTmpDir('ai-cancel');
|
|
t.after(() => rmrf(root));
|
|
const service = new TextIntelService({
|
|
store: { settingsDir: root },
|
|
settings: makeSettings({ timeoutMs: 60000 }),
|
|
dataDir: root,
|
|
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
|
init.signal.addEventListener('abort', () => {
|
|
const err = new Error('aborted');
|
|
err.name = 'AbortError';
|
|
reject(err);
|
|
});
|
|
}),
|
|
});
|
|
const pending = service.testAiConnection();
|
|
// Give the request a tick to register in the inflight set.
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(service.inflight.size, 1);
|
|
service.cancelInflight();
|
|
const result = await pending;
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.reason, /cancel/i);
|
|
});
|
|
|
|
// ---- typed-text capture is off by default ----------------------------------
|
|
|
|
function makeCaptureService(settingsOverrides = {}) {
|
|
const settingsData = { 'capture.mode': 'fullscreen', ...settingsOverrides };
|
|
return new CaptureService({
|
|
store: {},
|
|
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
|
|
getWindow: () => null,
|
|
notify: () => {},
|
|
screenApi: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getAllDisplays: () => [] },
|
|
});
|
|
}
|
|
|
|
test('raw typed text is ignored unless explicitly enabled', () => {
|
|
const off = makeCaptureService();
|
|
off.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
|
off.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
|
assert.equal(off.snapshotKeyContext().recentTyped, '', 'typed text must not be buffered by default');
|
|
|
|
const on = makeCaptureService({ 'capture.captureTypedText': true });
|
|
on.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
|
on.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
|
assert.equal(on.snapshotKeyContext().recentTyped, 'hi');
|
|
});
|
|
|
|
test('shortcut detection still works with typed text disabled', () => {
|
|
const off = makeCaptureService();
|
|
off.onKeyboardEvent('KEY', 'Ctrl+T', Date.now());
|
|
assert.equal(off.snapshotKeyContext().recentShortcut, 'Ctrl+T');
|
|
});
|
|
|
|
// ---- stale AI responses cannot overwrite user edits --------------------------
|
|
|
|
test('an AI patch built from stale data does not clobber a mid-generation user edit', async (t) => {
|
|
const { GuideStore } = require('../../core/store');
|
|
const root = makeTmpDir('ai-stale-write');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'G' });
|
|
const step = store.addStep(guide.guideId, { title: 'original title' });
|
|
|
|
const service = new TextIntelService({
|
|
store,
|
|
settings: makeSettings(),
|
|
dataDir: root,
|
|
fetchImpl: async (url) => {
|
|
const pathname = new URL(url).pathname;
|
|
if (pathname === '/api/show') {
|
|
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
|
|
}
|
|
if (pathname === '/api/chat') {
|
|
// While the model "thinks", the user edits and saves the step.
|
|
const current = store.getStep(guide.guideId, step.stepId);
|
|
store.saveStep(guide.guideId, { ...current, title: 'user edit during generation' });
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ message: { content: JSON.stringify({ title: 'AI title from stale context' }) } }),
|
|
};
|
|
}
|
|
throw new Error(`unexpected fetch: ${pathname}`);
|
|
},
|
|
});
|
|
|
|
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.reason, /changed while AI was generating/i);
|
|
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit during generation');
|
|
});
|
|
|
|
test('an AI patch applies cleanly when nothing changed during generation', async (t) => {
|
|
const { GuideStore } = require('../../core/store');
|
|
const root = makeTmpDir('ai-clean-write');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'G' });
|
|
const step = store.addStep(guide.guideId, { title: '' });
|
|
|
|
const service = new TextIntelService({
|
|
store,
|
|
settings: makeSettings(),
|
|
dataDir: root,
|
|
fetchImpl: async (url) => {
|
|
const pathname = new URL(url).pathname;
|
|
if (pathname === '/api/show') {
|
|
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
|
|
}
|
|
if (pathname === '/api/chat') {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ message: { content: JSON.stringify({ title: 'Generated title' }) } }),
|
|
};
|
|
}
|
|
throw new Error(`unexpected fetch: ${pathname}`);
|
|
},
|
|
});
|
|
|
|
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'Generated title');
|
|
});
|
|
|
|
// ---- source-level guards ----------------------------------------------------
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
|
|
test('the Windows keyboard hook only emits characters when opted in', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'app/capture.js'), 'utf8');
|
|
// The CHAR emission must be guarded by the opt-in flag threaded into C#.
|
|
assert.match(src, /CaptureTypedText = \$\{captureTypedText\}/);
|
|
assert.match(src, /else if \(CaptureTypedText\) \{/);
|
|
});
|