test: add rendered gui smoke harness

This commit is contained in:
mirivlad 2026-06-04 18:59:07 +08:00
parent 20e605bab7
commit 767c03ba8c
5 changed files with 742 additions and 1 deletions

View File

@ -67,6 +67,7 @@
```
Она проверяет локали, production-сборку фронтенда, актуальность embedded Wails assets и компиляцию GUI-бинаря.
Дополнительно запускается headless Chromium smoke через Wails-mock: проверяются first-run, recovery, основное окно, настройки, workspace, вкладки дела, файлы, журнал, активность и мобильный viewport. Скриншоты пишутся в `/tmp/verstak-gui-smoke`.
Бинарники попадают в `build/`:
- `verstak-gui-linux-amd64` — GUI-приложение

View File

@ -317,6 +317,9 @@ verstak/
# GUI smoke check перед коммитом
./scripts/check-gui.sh
# Только rendered smoke фронтенда с Wails-mock
cd frontend && npm run smoke:gui
# Dev-режим GUI
cd frontend && npm run dev
```

View File

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build --mode production",
"preview": "vite preview"
"preview": "vite preview",
"smoke:gui": "node ../scripts/check-gui-render.mjs"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2",

732
scripts/check-gui-render.mjs Executable file
View File

@ -0,0 +1,732 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
const ROOT = path.resolve(new URL('..', import.meta.url).pathname)
const FRONTEND = path.join(ROOT, 'frontend')
const OUT_DIR = process.env.GUI_SMOKE_OUT || path.join(os.tmpdir(), 'verstak-gui-smoke')
const CHROMIUM = process.env.CHROMIUM_BIN || findChromium()
const HOST = '127.0.0.1'
const flowUnderTest = 'app loads -> first meaningful screen renders -> primary visible controls respond without runtime errors.'
if (!CHROMIUM) {
fail('Chromium executable not found. Set CHROMIUM_BIN or install chromium.')
}
if (!existsSync(path.join(FRONTEND, 'dist', 'index.html'))) {
fail('frontend/dist is missing. Run npm run build before GUI render smoke.')
}
const processes = []
let browser = null
let preview = null
process.on('SIGINT', async () => {
await cleanup()
process.exit(130)
})
process.on('SIGTERM', async () => {
await cleanup()
process.exit(143)
})
async function main() {
console.log('=== GUI render smoke ===')
console.log(`Flow under test: ${flowUnderTest}`)
console.log(`Browser plugin not available; using local Chromium/CDP: ${CHROMIUM}`)
await rm(OUT_DIR, { recursive: true, force: true })
await mkdir(OUT_DIR, { recursive: true })
const previewPort = await freePort()
const cdpPort = await freePort()
preview = await startPreview(previewPort)
const baseUrl = `http://${HOST}:${previewPort}`
await waitForHttp(baseUrl, 15000)
browser = await startChromium(cdpPort)
const cdp = await openPage(cdpPort)
await cdp.send('Page.enable')
await cdp.send('Runtime.enable')
await cdp.send('Log.enable')
const consoleIssues = []
cdp.on('Runtime.consoleAPICalled', (event) => {
if (['error', 'warning', 'assert'].includes(event.type)) {
const text = (event.args || []).map((arg) => arg.value ?? arg.description ?? '').join(' ')
consoleIssues.push({ type: event.type, text })
}
})
cdp.on('Runtime.exceptionThrown', (event) => {
consoleIssues.push({ type: 'exception', text: event.exceptionDetails?.text || 'Runtime exception' })
})
cdp.on('Log.entryAdded', (event) => {
const entry = event.entry || {}
if (['error', 'warning'].includes(entry.level)) {
consoleIssues.push({ type: entry.level, text: entry.text || '' })
}
})
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: wailsMockSource() })
await runStartupScenario(cdp, `${baseUrl}/?smokeMode=first_run`, 'first-run', '.first-run-screen')
await runStartupScenario(cdp, `${baseUrl}/?smokeMode=recovery`, 'recovery', '.recovery-screen')
await runReadyScenario(cdp, `${baseUrl}/?smokeMode=ready`)
const relevantIssues = consoleIssues.filter((issue) => {
const text = issue.text || ''
return !text.includes('Failed to load resource: the server responded with a status of 404')
})
const errors = relevantIssues.filter((issue) => issue.type !== 'warning')
if (errors.length > 0) {
const lines = errors.map((issue) => `- ${issue.type}: ${issue.text}`).join('\n')
fail(`GUI render smoke saw console/runtime errors:\n${lines}`)
}
if (relevantIssues.length > 0) {
const lines = relevantIssues.map((issue) => `- ${issue.type}: ${issue.text}`).join('\n')
console.log(`Console warnings:\n${lines}`)
}
console.log(`OK: GUI render smoke passed. Screenshots: ${OUT_DIR}`)
cdp.close()
}
async function runStartupScenario(cdp, url, name, selector) {
await navigate(cdp, url)
await waitForSelector(cdp, selector)
if (name === 'first-run') {
await assertEval(cdp, `document.body.innerText.includes('Верстак')`, `${name}: app identity text visible`)
} else {
await assertEval(cdp, `document.body.innerText.includes('/tmp/missing-verstak-vault')`, `${name}: missing vault path visible`)
}
await screenshot(cdp, `${name}.png`)
console.log(`OK: ${name} screen renders`)
}
async function runReadyScenario(cdp, url) {
await navigate(cdp, url)
await waitForSelector(cdp, '.app')
await assertText(cdp, 'Верстак', 'ready: brand visible')
await assertEval(cdp, `document.querySelectorAll('.nav-item').length >= 3`, 'ready: system navigation rendered')
await assertEval(cdp, `document.querySelectorAll('.tree-item').length >= 2`, 'ready: workspace tree rendered')
await screenshot(cdp, 'ready-main.png')
await click(cdp, '.sidebar-settings-btn')
await waitForSelector(cdp, '.settings-window')
await assertEval(cdp, `document.querySelectorAll('.settings-nav-icon svg').length === 8`, 'settings: all sidebar icons are SVG')
await assertEval(cdp, `[...document.querySelectorAll('.settings-nav-icon')].every((el) => el.textContent.trim() === '')`, 'settings: no text glyph icons remain')
await clickText(cdp, '.settings-nav-item', 'Синхронизация')
await assertText(cdp, 'Настройте подключение к серверу синхронизации.', 'settings: sync section opens')
await clickText(cdp, '.settings-nav-item', 'Шаблоны')
await assertText(cdp, 'Шаблоны', 'settings: templates section opens')
await clickText(cdp, '.settings-nav-item', 'Рабочее пространство')
await assertText(cdp, 'Рабочее пространство', 'settings: workspace section opens')
await screenshot(cdp, 'settings.png')
await click(cdp, '.close-btn')
await waitForGone(cdp, '.settings-window')
await clickText(cdp, '.tree-label', 'Smoke Project')
await waitForSelector(cdp, '.tabs')
await assertText(cdp, 'Smoke Project', 'node: selected project visible')
await assertEval(cdp, `document.querySelectorAll('.tab').length === 6`, 'node: all primary tabs rendered')
await screenshot(cdp, 'node-overview.png')
await clickText(cdp, '.tab', 'Заметки')
await assertText(cdp, 'Smoke note', 'notes: existing note visible')
await clickText(cdp, '.tab', 'Файлы')
await waitForSelector(cdp, '.file-row')
await assertText(cdp, 'Assets', 'files: folder row visible')
await clickFolderOpenButton(cdp, 'Assets')
await waitForSelector(cdp, '.back-btn')
await assertEval(cdp, `document.querySelector('.back-btn')?.innerText.trim() === 'Назад'`, 'files: back button has one textual label')
await screenshot(cdp, 'files-folder.png')
await clickText(cdp, '.tab', 'Действия')
await assertText(cdp, 'Deploy smoke', 'actions: action card visible')
await clickText(cdp, '.tab', 'Журнал')
await assertText(cdp, 'Manual smoke entry', 'worklog: entry visible')
await clickText(cdp, '.tab', 'Активность')
await assertText(cdp, 'Smoke activity', 'activity: per-node activity visible')
await clickText(cdp, '.nav-item', 'Сегодня')
await assertText(cdp, 'Сегодня', 'today: system view opens')
await assertText(cdp, 'Smoke Project', 'today: dashboard data visible')
await clickText(cdp, '.nav-item', 'Журнал')
await waitForSelector(cdp, '.journal-screen')
await screenshot(cdp, 'journal.png')
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('фильтры')`, 'journal: filter section visible')
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('экспорт отчёта')`, 'journal: export section visible')
await clickText(cdp, '.nav-item', 'Активность')
await assertText(cdp, 'Активность', 'activity feed: system view opens')
await assertText(cdp, 'Smoke activity', 'activity feed: event visible')
await click(cdp, '.nav-add-btn')
await waitForSelector(cdp, '.modal-create')
await assertText(cdp, 'Создать элемент', 'create node: modal opens')
await screenshot(cdp, 'create-node-modal.png')
await clickText(cdp, '.modal-actions .btn', 'Отмена')
await waitForGone(cdp, '.modal-create')
await setViewport(cdp, 390, 844)
await navigate(cdp, url)
await waitForSelector(cdp, '.app')
await assertEval(cdp, `document.documentElement.scrollWidth <= window.innerWidth + 2`, 'mobile: no horizontal page overflow')
await screenshot(cdp, 'mobile-main.png')
console.log('OK: ready app smoke covers settings, workspace, tabs, files, journal, activity, create modal, mobile viewport')
}
async function navigate(cdp, url) {
await cdp.send('Page.navigate', { url })
await waitForEvent(cdp, 'Page.loadEventFired', 15000)
await waitFor(cdp, `document.readyState === 'complete'`, 10000)
}
async function setViewport(cdp, width, height) {
await cdp.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: width < 600,
})
}
async function screenshot(cdp, name) {
const result = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
await writeFile(path.join(OUT_DIR, name), Buffer.from(result.data, 'base64'))
}
async function waitForSelector(cdp, selector, timeout = 10000) {
await waitFor(cdp, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, timeout)
}
async function waitForGone(cdp, selector, timeout = 10000) {
await waitFor(cdp, `!document.querySelector(${JSON.stringify(selector)})`, timeout)
}
async function waitFor(cdp, expression, timeout = 10000) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const value = await evalValue(cdp, expression)
if (value) return
await sleep(100)
}
throw new Error(`Timed out waiting for: ${expression}`)
}
async function assertText(cdp, text, label) {
await waitFor(cdp, `document.body.innerText.includes(${JSON.stringify(text)})`, 10000)
console.log(`OK: ${label}`)
}
async function assertEval(cdp, expression, label) {
const ok = await evalValue(cdp, expression)
if (!ok) throw new Error(`Assertion failed: ${label}`)
console.log(`OK: ${label}`)
}
async function click(cdp, selector) {
const ok = await evalValue(cdp, `
(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
el.click();
return true;
})()
`)
if (!ok) throw new Error(`Click target not found: ${selector}`)
await sleep(150)
}
async function clickText(cdp, selector, text) {
const ok = await evalValue(cdp, `
(() => {
const norm = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(text)}));
if (!el) return false;
el.click();
return true;
})()
`)
if (!ok) throw new Error(`Click text target not found: ${selector} -> ${text}`)
await sleep(200)
}
async function clickFolderOpenButton(cdp, name) {
const ok = await evalValue(cdp, `
(() => {
const rows = [...document.querySelectorAll('.file-row')];
const row = rows.find((el) => el.innerText.includes(${JSON.stringify(name)}));
if (!row) return false;
const btn = row.querySelector('[title="Открыть папку"], [aria-label="Открыть папку"]');
if (!btn) return false;
btn.click();
return true;
})()
`)
if (!ok) throw new Error(`Folder open button not found for ${name}`)
await sleep(300)
}
async function evalValue(cdp, expression) {
const result = await cdp.send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
userGesture: true,
})
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || 'Runtime.evaluate exception')
}
return result.result?.value
}
async function startPreview(port) {
const viteBin = path.join(FRONTEND, 'node_modules', '.bin', process.platform === 'win32' ? 'vite.cmd' : 'vite')
const child = spawn(viteBin, ['preview', '--host', HOST, '--port', String(port), '--strictPort'], {
cwd: FRONTEND,
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
})
processes.push(child)
child.stdout.on('data', (buf) => process.stdout.write(prefixLines('vite', buf)))
child.stderr.on('data', (buf) => process.stderr.write(prefixLines('vite', buf)))
child.on('exit', (code) => {
if (code !== null && code !== 0) console.error(`vite preview exited with code ${code}`)
})
return child
}
async function startChromium(port) {
const profile = await mkdtemp(path.join(os.tmpdir(), 'verstak-chromium-'))
const child = spawn(CHROMIUM, [
'--headless=new',
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-default-browser-check',
'--disable-background-networking',
'--disable-sync',
'--metrics-recording-only',
'--disable-component-update',
'--disable-extensions',
`--remote-debugging-port=${port}`,
`--user-data-dir=${profile}`,
'--window-size=1280,900',
'about:blank',
], {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
})
child.__profile = profile
processes.push(child)
child.stderr.on('data', (buf) => {
const text = buf.toString()
const noisy = [
'DevTools listening',
'google_apis/gcm/engine/registration_request.cc',
'PHONE_REGISTRATION_ERROR',
]
if (!noisy.some((part) => text.includes(part))) process.stderr.write(prefixLines('chromium', buf))
})
await waitForHttp(`http://${HOST}:${port}/json/version`, 15000)
return child
}
async function openPage(port) {
let target
const newUrl = `http://${HOST}:${port}/json/new?about:blank`
let response = await fetch(newUrl, { method: 'PUT' })
if (!response.ok) response = await fetch(newUrl)
if (!response.ok) throw new Error(`Unable to create Chromium target: ${response.status}`)
target = await response.json()
return CDP.connect(target.webSocketDebuggerUrl)
}
class CDP {
constructor(ws) {
this.ws = ws
this.nextId = 1
this.pending = new Map()
this.listeners = new Map()
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.id && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id)
this.pending.delete(msg.id)
if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)))
else resolve(msg.result || {})
return
}
const listeners = this.listeners.get(msg.method) || []
for (const fn of listeners) fn(msg.params || {})
}
}
static async connect(url) {
const ws = new WebSocket(url)
await new Promise((resolve, reject) => {
ws.onopen = resolve
ws.onerror = () => reject(new Error(`WebSocket connection failed: ${url}`))
})
return new CDP(ws)
}
on(method, fn) {
const list = this.listeners.get(method) || []
list.push(fn)
this.listeners.set(method, list)
}
send(method, params = {}) {
const id = this.nextId++
this.ws.send(JSON.stringify({ id, method, params }))
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id)
reject(new Error(`CDP timeout: ${method}`))
}
}, 15000)
})
}
close() {
try {
this.ws.close()
} catch {}
}
}
function waitForEvent(cdp, method, timeout) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), timeout)
cdp.on(method, (params) => {
clearTimeout(timer)
resolve(params)
})
})
}
async function waitForHttp(url, timeout) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
try {
const response = await fetch(url)
if (response.ok) return
} catch {}
await sleep(150)
}
throw new Error(`Timed out waiting for ${url}`)
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => resolve(address.port))
})
server.on('error', reject)
})
}
async function cleanup() {
for (const child of processes.reverse()) {
if (!child.killed) child.kill('SIGTERM')
if (child.__profile) await rm(child.__profile, { recursive: true, force: true }).catch(() => {})
}
}
function findChromium() {
const candidates = [
process.env.CHROMIUM_BIN,
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
].filter(Boolean)
return candidates.find((candidate) => existsSync(candidate)) || ''
}
function prefixLines(label, buf) {
return buf.toString().split('\n').filter(Boolean).map((line) => `[${label}] ${line}\n`).join('')
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function fail(message) {
console.error(`FAIL: ${message}`)
process.exit(1)
}
try {
await main()
} finally {
await cleanup()
}
function wailsMockSource() {
return `
(() => {
const now = '2026-06-04T10:00:00Z';
const clone = (value) => JSON.parse(JSON.stringify(value));
const mode = new URL(location.href).searchParams.get('smokeMode') || 'ready';
const templates = [
{ id: 'folder.default', title: 'template.folder.default', icon: 'folder', enabled: true },
{ id: 'project.default', title: 'template.project.default', icon: 'project', enabled: true },
{ id: 'client.default', title: 'template.client.default', icon: 'client', enabled: true },
{ id: 'document.default', title: 'template.document.default', icon: 'document', enabled: true },
{ id: 'recipe.default', title: 'template.recipe.default', icon: 'recipe', enabled: true },
];
const state = {
nodes: [
{
id: 'node-project',
title: 'Smoke Project',
type: 'project',
section: 'projects',
createdAt: now,
has_children: true,
children: [
{ id: 'node-folder', title: 'Research', type: 'folder', section: 'projects', parent_id: 'node-project', createdAt: now, has_children: false, children: [] },
],
},
{ id: 'node-client', title: 'Smoke Client', type: 'client', section: 'clients', createdAt: now, has_children: false, children: [] },
],
notes: {
'node-project': [{ id: 'note-1', title: 'Smoke note', createdAt: now }],
},
noteContent: {
'note-1': '# Smoke note\\\\nRendered by GUI smoke.',
},
files: {
'node-project': [
{ id: 'file-folder', nodeId: 'file-folder', name: 'Assets', type: 'folder', size: 0, createdAt: now },
{ id: 'file-brief', nodeId: 'file-brief', fileId: 'file-brief', name: 'brief.md', type: 'file', size: 1280, createdAt: now },
],
'file-folder': [
{ id: 'file-nested', nodeId: 'file-nested', fileId: 'file-nested', name: 'nested.txt', type: 'file', size: 64, createdAt: now },
],
},
actions: {
'node-project': [{ id: 'action-1', title: 'Deploy smoke', type: 'open_url', data: 'https://example.test' }],
},
worklog: {
'node-project': [{ id: 'wl-1', nodeId: 'node-project', summary: 'Manual smoke entry', details: 'Smoke details', minutes: 45, billable: true, approximate: false, source: 'manual', createdAt: now, date: '2026-06-04' }],
},
};
const fileNodeDetails = {
'file-folder': { id: 'file-folder', title: 'Assets', type: 'folder', parent_id: 'node-project', createdAt: now, has_children: false, children: [] },
'file-brief': { id: 'file-brief', title: 'brief.md', type: 'file', parent_id: 'node-project', createdAt: now, has_children: false },
'file-nested': { id: 'file-nested', title: 'nested.txt', type: 'file', parent_id: 'file-folder', createdAt: now, has_children: false },
};
const events = [
{ id: 'event-1', nodeId: 'node-project', eventType: 'note_updated', title: 'Smoke activity', targetType: 'note', targetId: 'note-1', createdAt: now },
{ id: 'event-2', nodeId: 'node-project', eventType: 'file_added', title: 'brief.md', targetType: 'file', targetId: 'file-brief', targetPath: '/tmp/brief.md', createdAt: now },
];
function allNodes() {
const result = [];
function walk(items) {
for (const item of items) {
result.push(item);
if (item.children) walk(item.children);
}
}
walk(state.nodes);
return result;
}
function findNode(id) {
return allNodes().find((node) => node.id === id) || null;
}
function childrenOf(id) {
const node = findNode(id);
return node?.children || [];
}
function readyStatus() {
return {
status: 'ready',
vaultPath: '/tmp/verstak-smoke-vault',
vaultExists: true,
defaultPath: '/tmp/verstak-smoke-vault',
appConfig: appConfig(),
};
}
function appConfig() {
return {
version: 1,
vault_path: '/tmp/verstak-smoke-vault',
theme: 'system',
language: 'ru',
enabled_templates: templates.map((template) => template.id),
enabled_plugins: [],
first_run_completed: true,
vault: { sync: { enabled: false, server_url: '', sync_interval: 0 } },
};
}
const App = {
GetStartupStatus: async () => {
if (mode === 'first_run') return { status: 'first_run', defaultPath: '/tmp/verstak-smoke-vault' };
if (mode === 'recovery') return { status: 'recovery', vaultPath: '/tmp/missing-verstak-vault', defaultPath: '/tmp/verstak-smoke-vault', appConfig: appConfig() };
return readyStatus();
},
GetDefaultVaultPath: async () => '/tmp/verstak-smoke-vault',
CheckVaultPath: async () => ({ writable: true, description: 'Smoke path is writable' }),
CreateVault: async () => readyStatus(),
OpenVault: async () => readyStatus(),
PickDirectory: async () => '/tmp/verstak-smoke-vault',
Quit: async () => true,
VerstakVersion: async () => 'verstak-gui/smoke',
GetAppConfig: async () => appConfig(),
SaveAppConfig: async () => true,
GetVaultInfo: async () => ({ path: '/tmp/verstak-smoke-vault', nodeCount: allNodes().length, fileCount: 2 }),
VaultCheck: async () => ({ ok: true, errors: [], warnings: [] }),
ListSystemViews: async () => [
{ id: 'today', label: 'Сегодня' },
{ id: 'inbox', label: 'Неразобранное' },
{ id: 'activity', label: 'Активность' },
{ id: 'journal', label: 'Журнал' },
],
ListWorkspaceTree: async () => clone(state.nodes),
ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
ListEnabledTemplates: async () => clone(templates),
AllTemplates: async () => clone(templates),
SetTemplateEnabled: async () => true,
GetNodeDetail: async (id) => clone(findNode(id) || fileNodeDetails[id] || null),
GetNodeTitle: async (id) => findNode(id)?.title || '',
SearchNodes: async (query) => allNodes().filter((node) => node.title.toLowerCase().includes(String(query || '').toLowerCase())).map((node) => ({ id: node.id, title: node.title, path: '/Smoke/' + node.title })),
CreateNodeFromTemplate: async (parentId, title, templateId) => {
const id = 'node-created-' + Date.now();
const node = { id, title, type: templateId?.split('.')[0] || 'folder', section: 'projects', parent_id: parentId || '', createdAt: now, has_children: false, children: [] };
if (parentId) {
const parent = findNode(parentId);
if (parent) {
parent.children = parent.children || [];
parent.children.push(node);
parent.has_children = true;
}
} else {
state.nodes.push(node);
}
return clone(node);
},
MoveNode: async () => true,
DuplicateNode: async () => true,
DeleteNode: async () => true,
RenameNode: async () => true,
ValidateName: async () => true,
OpenFolder: async () => true,
ListTodayView: async () => ({
date: '2026-06-04',
summary: { changedCases: 1, notes: 1, files: 1 },
groups: [{ nodeId: 'node-project', nodeTitle: 'Smoke Project', nodeKind: 'project', lastActivityAt: now, events }],
events,
}),
ListActivityFeed: async () => clone(events),
ListActivityByNode: async (nodeId) => clone(events.filter((event) => event.nodeId === nodeId)),
ListNotes: async (nodeId) => clone(state.notes[nodeId] || []),
CreateNote: async (nodeId, title) => {
const note = { id: 'note-' + Date.now(), title, createdAt: now };
state.notes[nodeId] = [...(state.notes[nodeId] || []), note];
state.noteContent[note.id] = '# ' + title;
return clone(note);
},
ReadNote: async (id) => state.noteContent[id] || '',
SaveNote: async (id, content) => { state.noteContent[id] = content; return true; },
ListItems: async (folderId) => clone(state.files[folderId] || []),
ListFiles: async (nodeId) => clone(state.files[nodeId] || []),
CreateEmptyFile: async (parentId, name) => {
const file = { id: 'file-' + Date.now(), nodeId: 'file-' + Date.now(), fileId: 'file-' + Date.now(), name, type: 'file', size: 0, createdAt: now };
state.files[parentId] = [...(state.files[parentId] || []), file];
return clone(file);
},
PickFile: async () => '/tmp/smoke.txt',
PreviewImport: async () => ({ files: 1, folders: 0, totalBytes: 64, isDangerous: false }),
AddPathCopy: async () => true,
AddPathLink: async () => true,
DeleteFileOrFolder: async () => true,
OpenFile: async () => true,
GetFileBase64: async () => '',
ReadFileText: async () => 'Smoke file content',
ListActions: async (nodeId) => clone(state.actions[nodeId] || []),
CreateAction: async (nodeId, kind, title, data) => {
const action = { id: 'action-' + Date.now(), title, type: kind, data };
state.actions[nodeId] = [...(state.actions[nodeId] || []), action];
return clone(action);
},
DeleteAction: async () => true,
RunAction: async () => true,
ListWorklog: async (nodeId) => clone(state.worklog[nodeId] || []),
CreateWorklogFull: async (nodeId, summary, details, date, minutes, approximate, billable) => {
const entry = { id: 'wl-' + Date.now(), nodeId, summary, details, date, minutes, approximate, billable, source: 'manual', createdAt: now };
state.worklog[nodeId] = [...(state.worklog[nodeId] || []), entry];
return clone(entry);
},
GetSuggestions: async () => [{
nodeId: 'node-project',
nodeTitle: 'Smoke Project',
summary: 'Review smoke activity',
suggestedMin: 20,
confidence: 'high',
eventIds: events.map((event) => event.id),
events,
}],
AcceptSuggestionWith: async () => true,
ListWorklogReport: async () => clone(state.worklog['node-project'].map((entry) => ({ ...entry, nodeTitle: 'Smoke Project', nodePath: '/Smoke Project', _hasEvents: false }))),
WorklogReportSummary: async () => ({ totalMinutes: 45, totalEntries: 1, byDay: [{ label: '2026-06-04', minutes: 45, count: 1 }], byNode: [{ label: 'Smoke Project', minutes: 45, count: 1 }] }),
GetWorklogEntryEvents: async () => [],
SaveWorklogReport: async (format) => 'saved ' + format,
SyncStatus: async () => ({ configured: false, connected: false, revoked: false, unpushedOps: 0 }),
GetSyncSettings: async () => ({ enabled: false, serverUrl: '', syncInterval: 0, lastStatus: 'disabled' }),
SyncNow: async () => ({ pushed: 0, pulled: 0, conflicts: [], applyErrors: [] }),
SyncTestConnection: async () => true,
SyncConfigure: async () => true,
SyncSetInterval: async () => true,
SyncDisconnect: async () => true,
ResetSyncKey: async () => true,
WriteDebugLog: async () => true,
OpenPluginsFolder: async () => true,
OpenVaultFolder: async () => true,
};
window.go = { main: { App } };
window.runtime = {
EventsOn: () => {},
EventsOff: () => {},
};
window.__VERSTAK_GUI_SMOKE__ = { mode, state, events };
})();
`
}

View File

@ -34,4 +34,8 @@ echo ""
echo "=== Compiling GUI binary ==="
go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o "$GUI_BUILD_OUT" "$ROOT/cmd/verstak-gui/"
echo ""
echo "=== Running rendered GUI smoke ==="
node "$ROOT/scripts/check-gui-render.mjs"
echo "OK: GUI smoke check passed"