diff --git a/README.md b/README.md index 85a3ab0..f2a9e57 100644 --- a/README.md +++ b/README.md @@ -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-приложение diff --git a/docs/PLAN.md b/docs/PLAN.md index 8a6c1a0..4c6662b 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 ``` diff --git a/frontend/package.json b/frontend/package.json index b17287c..cba09d7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/scripts/check-gui-render.mjs b/scripts/check-gui-render.mjs new file mode 100755 index 0000000..95dc6b9 --- /dev/null +++ b/scripts/check-gui-render.mjs @@ -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 }; +})(); +` +} diff --git a/scripts/check-gui.sh b/scripts/check-gui.sh index 3c38b6c..950b46a 100755 --- a/scripts/check-gui.sh +++ b/scripts/check-gui.sh @@ -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"