Files
StepForge/tests/unit/ai-network.test.js
TylerandClaude Fable 5 c1ccb5739b
Template tests / tests (pull_request) Failing after 33s
Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data
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]>
2026-07-03 22:57:50 -05:00

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\) \{/);
});