Author SHA1 Message Date
Tyler c2e05c900f Remove linux support
Template tests / tests (push) Successful in 2m0s
2026-06-29 08:11:43 -05:00
11 changed files with 34 additions and 253 deletions
+3 -36
View File
@@ -2,8 +2,8 @@ name: Release
# Manual release: from the GitHub "Actions" tab pick "Release", click # Manual release: from the GitHub "Actions" tab pick "Release", click
# "Run workflow", enter the version tag, and it builds the Windows installer # "Run workflow", enter the version tag, and it builds the Windows installer
# .exe and the Linux tarball + .deb, then publishes a GitHub Release with all # .exe, then publishes a GitHub Release with that artifact attached and
# artifacts attached and auto-generated notes. # auto-generated notes.
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -68,41 +68,9 @@ jobs:
path: releases/*.exe path: releases/*.exe
if-no-files-found: error if-no-files-found: error
build-linux:
name: Build Linux tarball + .deb
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Set version from input
shell: bash
env:
VERSION: ${{ inputs.version }}
run: npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
- name: Package Linux artifacts (tarball + .deb)
shell: bash
env:
STEPFORGE_PACKAGE_DIR: ${{ github.workspace }}/build/artifacts
run: bash scripts/package-linux.sh
- uses: actions/upload-artifact@v4
with:
name: linux-artifacts
path: build/artifacts/*
if-no-files-found: error
release: release:
name: Publish GitHub Release name: Publish GitHub Release
needs: [build-windows, build-linux] needs: [build-windows]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -127,7 +95,6 @@ jobs:
fi fi
gh release create "$TAG" \ gh release create "$TAG" \
dist/windows-installer/*.exe \ dist/windows-installer/*.exe \
dist/linux-artifacts/* \
--title "StepForge $TAG" \ --title "StepForge $TAG" \
--target "${{ github.sha }}" \ --target "${{ github.sha }}" \
--generate-notes \ --generate-notes \
+10 -7
View File
@@ -1,9 +1,11 @@
# StepForge # StepForge
StepForge is a **fully offline**, open-source desktop app for Windows and StepForge is a **fully offline**, open-source desktop app for Windows, with
Linux that captures step-by-step workflows as screenshots, lets you annotate Linux (WIP) builds. It captures step-by-step workflows as screenshots, lets
and describe each step in a focused three-pane editor, and exports the result you annotate and describe each step in a focused three-pane editor, and
to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP), confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current reconmendations for exporting is Markdown and PDF. exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
reconmendations for exporting is Markdown and PDF.
It is an independent offline desktop guide-capture tool inspired by publicly It is an independent offline desktop guide-capture tool inspired by publicly
documented workflow patterns of commercial documentation tools like Folge. It contains no documented workflow patterns of commercial documentation tools like Folge. It contains no
@@ -60,7 +62,7 @@ using only Node built-ins.
## Getting Started ## Getting Started
For a windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md). For a Windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
Requirements: Node.js 20+ and npm (Electron is the only dependency). Requirements: Node.js 20+ and npm (Electron is the only dependency).
@@ -70,7 +72,8 @@ npm start # launch StepForge
``` ```
First run creates the local data directory (`~/.local/share/stepforge` on First run creates the local data directory (`~/.local/share/stepforge` on
Linux, `%APPDATA%/stepforge` on Windows; override with `STEPFORGE_DATA_DIR`). Linux (WIP), `%APPDATA%/stepforge` on Windows; override with
`STEPFORGE_DATA_DIR`).
## Testing ## Testing
@@ -92,7 +95,7 @@ documents, and validating the bytes of the output, not string matching.
bash scripts/bootstrap-offline.sh # verify toolchain availability bash scripts/bootstrap-offline.sh # verify toolchain availability
bash scripts/verify.sh # full test suite + smoke checks bash scripts/verify.sh # full test suite + smoke checks
bash scripts/build-release.sh # assemble runnable app directory bash scripts/build-release.sh # assemble runnable app directory
bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec) bash scripts/package-linux.sh # local Linux packaging (WIP; not part of release)
npm run package:windows # Windows installer .exe in releases/ npm run package:windows # Windows installer .exe in releases/
pwsh scripts/package-windows.ps1 # same Windows installer build via PowerShell pwsh scripts/package-windows.ps1 # same Windows installer build via PowerShell
``` ```
+1 -1
View File
@@ -1 +1 @@
In the future, planned updates include a re-vamp of the settings panel, text box's (important, tip, warning, and note) are not properly moving to the intended areas and I will look into locking the size (or being able to control the size) of images throughout varias export formats. OCR or local ai would be pretty cool.... In the future, planned updates include a re-vamp of the settings panel, text box's (important, tip, warning, and note) are not properly moving to the intended areas and I will look into locking the size (or being able to control the size) of images throughout varias export formats.
+2 -11
View File
@@ -318,13 +318,8 @@ function showSettingsDialog({
const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) }); const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) });
const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434'); const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434');
const ollamaModel = makeInput(settings.ai?.ollama?.model || 'llama3.2:1b'); const ollamaModel = makeInput(settings.ai?.ollama?.model || 'llama3.2:1b');
const aiStatus = el('div', { className: 'muted ai-status' }, 'AI stays local through Ollama. Vision-capable models can also inspect the screenshot attached to each step.'); const aiStatus = el('div', { className: 'muted ai-status' }, 'AI stays local through Ollama. Nothing is sent to the cloud.');
const testAiBtn = el('button', { type: 'button' }, 'Test connection'); const testAiBtn = el('button', { type: 'button' }, 'Test connection');
const persistOllamaModel = debounce(() => {
const model = ollamaModel.value.trim();
// Keep the last model choice even if the dialog is dismissed without a full save.
void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {});
}, 250);
const updateAiStatus = (message, { error = false } = {}) => { const updateAiStatus = (message, { error = false } = {}) => {
aiStatus.textContent = message; aiStatus.textContent = message;
@@ -346,9 +341,7 @@ function showSettingsDialog({
return; return;
} }
if (result.installed) { if (result.installed) {
updateAiStatus(result.vision updateAiStatus(`Connected to ${result.host} with ${result.model}.`);
? `Connected to ${result.host} with ${result.model}. It can inspect screenshots.`
: `Connected to ${result.host} with ${result.model}. This model is text-only, so StepForge will use OCR and metadata only.`);
} else { } else {
updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true }); updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true });
} }
@@ -358,8 +351,6 @@ function showSettingsDialog({
setButtonLoading(testAiBtn, false); setButtonLoading(testAiBtn, false);
} }
}; };
ollamaModel.addEventListener('input', () => persistOllamaModel());
ollamaModel.addEventListener('blur', () => persistOllamaModel.flush());
const placeholderRows = el('div', { className: 'placeholder-rows' }); const placeholderRows = el('div', { className: 'placeholder-rows' });
const rows = []; const rows = [];
+2 -74
View File
@@ -35,15 +35,6 @@ function clamp(v, min, max) {
return Math.min(max, Math.max(min, v)); return Math.min(max, Math.max(min, v));
} }
function modelLooksVisionCapable(model) {
const clean = normalizeWhitespace(model).toLowerCase();
if (!clean) return false;
return clean.includes('vision')
|| clean.includes('llava')
|| clean.includes('gemma4')
|| /(^|[^a-z0-9])qwen[23](?:\.[0-9]+)?vl([^a-z0-9]|$)/.test(clean);
}
let createWorkerImpl = null; let createWorkerImpl = null;
function loadCreateWorker() { function loadCreateWorker() {
if (createWorkerImpl) return createWorkerImpl; if (createWorkerImpl) return createWorkerImpl;
@@ -73,7 +64,6 @@ class TextIntelService {
this.workerPromise = null; this.workerPromise = null;
this.workerQueue = Promise.resolve(); this.workerQueue = Promise.resolve();
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng'); this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
this.modelCapabilityCache = new Map();
} }
async shutdown() { async shutdown() {
@@ -382,63 +372,15 @@ public static class Win32 {
const data = await res.json(); const data = await res.json();
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : []; const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
const installed = config.ollama.model ? models.includes(config.ollama.model) : false; const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
const vision = installed ? await this.modelSupportsVision({
host: config.ollama.host,
model: config.ollama.model,
}) : false;
return { return {
ok: true, ok: true,
installed, installed,
vision,
models, models,
host: config.ollama.host, host: config.ollama.host,
model: config.ollama.model, model: config.ollama.model,
}; };
} }
async modelCapabilities({ host, model }) {
const normalizedHost = normalizeOllamaHost(host);
const normalizedModel = normalizeWhitespace(model);
if (!normalizedHost || !normalizedModel) return [];
const cacheKey = `${normalizedHost}::${normalizedModel}`;
if (this.modelCapabilityCache.has(cacheKey)) {
return this.modelCapabilityCache.get(cacheKey);
}
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
let capabilities = [];
try {
const response = await this.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: normalizedModel }),
});
if (response.ok) {
const payload = await response.json();
capabilities = Array.isArray(payload?.capabilities)
? payload.capabilities.map((cap) => normalizeWhitespace(cap).toLowerCase()).filter(Boolean)
: [];
}
} catch {
capabilities = [];
}
if (!capabilities.includes('vision') && modelLooksVisionCapable(normalizedModel)) {
capabilities = [...capabilities, 'vision'];
}
this.modelCapabilityCache.set(cacheKey, capabilities);
return capabilities;
}
async modelSupportsVision({ host, model }) {
const capabilities = await this.modelCapabilities({ host, model });
return capabilities.includes('vision');
}
readStepImageBase64(guideId, stepId) {
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
if (!imagePath || !fs.existsSync(imagePath)) return '';
return fs.readFileSync(imagePath).toString('base64');
}
async callOllamaText({ host, model, prompt, systemPrompt }) { async callOllamaText({ host, model, prompt, systemPrompt }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`); const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const response = await this.fetch(url, { const response = await this.fetch(url, {
@@ -461,12 +403,8 @@ public static class Win32 {
return content.trim(); return content.trim();
} }
async callOllama({ host, model, prompt, systemPrompt, images = [] }) { async callOllama({ host, model, prompt, systemPrompt }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`); const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const userMessage = { role: 'user', content: prompt };
if (Array.isArray(images) && images.length) {
userMessage.images = images;
}
const response = await this.fetch(url, { const response = await this.fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -476,7 +414,7 @@ public static class Win32 {
format: 'json', format: 'json',
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
userMessage, { role: 'user', content: prompt },
], ],
options: { options: {
temperature: 0.2, temperature: 0.2,
@@ -522,14 +460,6 @@ public static class Win32 {
return { ok: false, reason: 'Block not found.' }; return { ok: false, reason: 'Block not found.' };
} }
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
const screenshotAttached = Boolean(screenshotBase64)
? await this.modelSupportsVision({
host: config.ollama.host,
model: config.ollama.model,
})
: false;
let captureContext = null; let captureContext = null;
// Use stored capture metadata when available (best context, from capture time). // Use stored capture metadata when available (best context, from capture time).
// Fall back to re-running OCR on the stored image only when metadata is absent. // Fall back to re-running OCR on the stored image only when metadata is absent.
@@ -584,7 +514,6 @@ public static class Win32 {
step, step,
captureContext, captureContext,
block: currentBlock, block: currentBlock,
screenshotAttached,
}); });
const raw = await this.callOllama({ const raw = await this.callOllama({
@@ -592,7 +521,6 @@ public static class Win32 {
model: config.ollama.model, model: config.ollama.model,
prompt, prompt,
systemPrompt, systemPrompt,
images: screenshotAttached ? [screenshotBase64] : [],
}); });
const patch = normalizeAiPatch(raw); const patch = normalizeAiPatch(raw);
const updated = applyAiPatchToStep(step, patch, { target, blockId }); const updated = applyAiPatchToStep(step, patch, { target, blockId });
-5
View File
@@ -673,7 +673,6 @@ function buildAiPrompt({
step = null, step = null,
captureContext = null, captureContext = null,
block = null, block = null,
screenshotAttached = false,
} = {}) { } = {}) {
const hasDraftTitle = step && !isPlaceholderTitle(step.title); const hasDraftTitle = step && !isPlaceholderTitle(step.title);
const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || '')); const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || ''));
@@ -718,7 +717,6 @@ function buildAiPrompt({
(!hasDraftTitle || target === 'description') && captureContext.titleCandidate (!hasDraftTitle || target === 'description') && captureContext.titleCandidate
? `Suggested title: ${captureContext.titleCandidate}` : null, ? `Suggested title: ${captureContext.titleCandidate}` : null,
] : []), ] : []),
screenshotAttached ? 'Screenshot: attached to this request.' : null,
draftTitleLine, draftTitleLine,
draftDescLine, draftDescLine,
].filter(Boolean); ].filter(Boolean);
@@ -772,9 +770,6 @@ function buildAiPrompt({
richContext richContext
? '- Use the OCR text, window title, app name, and element info to make the documentation specific.' ? '- Use the OCR text, window title, app name, and element info to make the documentation specific.'
: '- Context is limited. Use the app name or window title if available; generate a reasonable action title.', : '- Context is limited. Use the app name or window title if available; generate a reasonable action title.',
screenshotAttached
? '- A screenshot is attached. Use it together with the OCR and metadata to resolve visual details, but do not mention the screenshot in the output.'
: '- No screenshot is attached. Rely on OCR, the window title, app name, and element info.',
'- Do NOT generate blocks that describe the technical capture process or mention OCR.', '- Do NOT generate blocks that describe the technical capture process or mention OCR.',
'- Do NOT invent details not supported by the capture context.', '- Do NOT invent details not supported by the capture context.',
'- If the target is one block, only rewrite that block.', '- If the target is one block, only rewrite that block.',
+1 -1
View File
@@ -111,7 +111,7 @@ click position. Three pieces make that hold:
1. **OS click events** (`app/capture.js`): a low-level mouse hook on Windows 1. **OS click events** (`app/capture.js`): a low-level mouse hook on Windows
(`CLICK x y button unixMs` lines), an `xinput test-xi2 --root` watcher on (`CLICK x y button unixMs` lines), an `xinput test-xi2 --root` watcher on
X11. The Linux parser carries event-time `root:` coordinates and merges X11. The Linux (WIP) parser carries event-time `root:` coordinates and merges
raw/regular twin blocks structurally — there is no time-based debounce raw/regular twin blocks structurally — there is no time-based debounce
that could drop fast clicks, only suppression of identical duplicate that could drop fast clicks, only suppression of identical duplicate
deliveries. Physical coordinates convert to DIP via deliveries. Physical coordinates convert to DIP via
+5 -4
View File
@@ -25,9 +25,9 @@ That installs Electron and the local packaging tools used by the scripts.
npm start npm start
``` ```
The first launch creates the local StepForge data directory. On Linux it is The first launch creates the local StepForge data directory. On Linux (WIP)
usually under `~/.local/share/stepforge`. On Windows it is usually under it is usually under `~/.local/share/stepforge`. On Windows it is usually
`%APPDATA%/stepforge`. under `%APPDATA%/stepforge`.
## 3. Create your first guide ## 3. Create your first guide
@@ -95,4 +95,5 @@ If you want to find commands quickly, press `Ctrl+/` for Quick Actions.
1. `bash scripts/build-release.sh` assembles the offline release layout. 1. `bash scripts/build-release.sh` assembles the offline release layout.
2. `npm run package:windows` creates the Windows installer `.exe` in 2. `npm run package:windows` creates the Windows installer `.exe` in
`releases/`. `releases/`.
3. `bash scripts/package-linux.sh` creates Linux release artifacts. 3. `bash scripts/package-linux.sh` creates Linux release artifacts (WIP;
local only).
+1 -10
View File
@@ -22,14 +22,6 @@ ollama pull llama3.2:1b
That model is small enough to feel responsive on modest hardware, but still good enough for human-sounding titles and short text blocks. That model is small enough to feel responsive on modest hardware, but still good enough for human-sounding titles and short text blocks.
If you want StepForge to send the screenshot itself to the model, pull a vision-capable model instead:
```bash
ollama pull gemma3
```
That model can inspect pictures as well as text, so it is better when you want the AI to read the UI directly from the screenshot.
If you need something even smaller, try: If you need something even smaller, try:
```bash ```bash
@@ -52,7 +44,7 @@ Set:
* `Enable AI text filling` to on * `Enable AI text filling` to on
* `Ollama host` to your local Ollama server * `Ollama host` to your local Ollama server
* `Ollama model` to `llama3.2:1b` for text-only mode, or `llama3.2-vision` if you want screenshot-aware AI * `Ollama model` to `llama3.2:1b` or the smaller model you pulled
The default host is: The default host is:
@@ -81,4 +73,3 @@ You can also use `More -> Generate all text fields with AI` to fill the whole st
* Capture titles are still generated automatically without AI. * Capture titles are still generated automatically without AI.
* AI generation only works when `Enable AI text filling` is turned on. * AI generation only works when `Enable AI text filling` is turned on.
* The app always uses local OCR around the click area first, then local AI only when you ask for it. * The app always uses local OCR around the click area first, then local AI only when you ask for it.
* When the selected Ollama model supports vision, StepForge also sends the screenshot to the model so it can cross-check OCR and visual context.
-2
View File
@@ -56,14 +56,12 @@ test('settings persist, deep-merge with defaults, and store global placeholders'
assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance); assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance);
s1.set('appearance', 'dark'); s1.set('appearance', 'dark');
s1.set('capture.delayMs', 1500); s1.set('capture.delayMs', 1500);
s1.set('ai.ollama.model', 'qwen3:0.6b');
s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' }); s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' });
// A fresh instance reads back the changed values merged over defaults. // A fresh instance reads back the changed values merged over defaults.
const s2 = new Settings(dir); const s2 = new Settings(dir);
assert.equal(s2.get('appearance'), 'dark'); assert.equal(s2.get('appearance'), 'dark');
assert.equal(s2.get('capture.delayMs'), 1500); assert.equal(s2.get('capture.delayMs'), 1500);
assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b');
assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker); assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker);
assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' }); assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
}); });
+1 -94
View File
@@ -1,7 +1,5 @@
'use strict'; 'use strict';
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -311,10 +309,7 @@ test('ollama connection test reports installed models', async (t) => {
settings: makeSettings(), settings: makeSettings(),
getWindow: () => null, getWindow: () => null,
dataDir: root, dataDir: root,
fetchImpl: async (url) => { fetchImpl: async () => ({
const pathname = new URL(url).pathname;
if (pathname === '/api/tags') {
return {
ok: true, ok: true,
json: async () => ({ json: async () => ({
models: [ models: [
@@ -322,101 +317,13 @@ test('ollama connection test reports installed models', async (t) => {
{ name: 'qwen3:0.6b' }, { name: 'qwen3:0.6b' },
], ],
}), }),
};
}
if (pathname === '/api/show') {
return {
ok: true,
json: async () => ({
capabilities: ['completion', 'vision'],
}), }),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
}); });
const result = await service.testAiConnection(); const result = await service.testAiConnection();
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.installed, true); assert.equal(result.installed, true);
assert.equal(result.model, 'llama3.2:1b'); assert.equal(result.model, 'llama3.2:1b');
assert.equal(result.vision, true);
});
test('vision-capable models receive the screenshot in the chat request', async (t) => {
const root = makeTmpDir('text-intel-ai-vision');
t.after(() => rmrf(root));
const imagePath = path.join(root, 'step.png');
fs.writeFileSync(imagePath, Buffer.from('fake screenshot bytes'));
const step = createStep({
title: 'Old title',
descriptionHtml: '<p>Old text</p>',
image: {
originalPath: 'original.png',
workingPath: 'working.png',
size: { width: 10, height: 10 },
},
captureMetadata: {
windowTitle: 'Settings',
appName: 'chrome',
ocrText: 'Open settings',
titleCandidate: 'Open settings',
mode: 'fullscreen',
},
});
const fetchCalls = [];
const service = new TextIntelService({
store: {
settingsDir: root,
getGuide: () => ({ guideId: 'g1', title: 'Guide', descriptionHtml: '', stepsOrder: ['s1'] }),
getStep: () => step,
stepImagePath: () => imagePath,
saveStep: (_, next) => next,
},
settings: makeSettings(),
getWindow: () => null,
dataDir: root,
fetchImpl: async (url, init = {}) => {
const pathname = new URL(url).pathname;
fetchCalls.push({ pathname, init });
if (pathname === '/api/show') {
return {
ok: true,
json: async () => ({
capabilities: ['completion', 'vision'],
}),
};
}
if (pathname === '/api/chat') {
return {
ok: true,
json: async () => ({
message: {
content: JSON.stringify({
title: 'Open settings',
description: 'Use the AI tab.',
}),
},
}),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
});
const result = await service.generateStepPatch({
guideId: 'g1',
stepId: 's1',
target: 'all',
});
assert.equal(result.ok, true);
const chatCall = fetchCalls.find((call) => call.pathname === '/api/chat');
assert.ok(chatCall, 'expected an Ollama chat request');
const body = JSON.parse(chatCall.init.body);
assert.deepEqual(body.messages[1].images, [fs.readFileSync(imagePath).toString('base64')]);
assert.match(body.messages[1].content, /Screenshot: attached/i);
}); });
test('invalid ollama output fails safely without saving the step', async (t) => { test('invalid ollama output fails safely without saving the step', async (t) => {