#!/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 and mutate UI state 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 waitForSelector(cdp, '.global-search-input') await assertEval(cdp, `document.querySelector('.global-search-input')?.placeholder === 'Поиск по Верстаку...'`, 'header: global search is visible') await dispatchShortcut(cdp, 'k', true) await assertEval(cdp, `document.activeElement === document.querySelector('.global-search-input')`, 'header: Ctrl+K focuses global search') await setInputValue(cdp, '.global-search-input', 'Smoke') await waitForSelector(cdp, '.global-search-results') await assertText(cdp, 'Проект · Верстак / Smoke Project', 'search: node result shows type and breadcrumbs') await assertText(cdp, 'Заметка · Верстак / Smoke Project', 'search: note result shows type and breadcrumbs') await assertText(cdp, 'Файл · Верстак / Smoke Project / Assets', 'search: file result shows type and breadcrumbs') await assertText(cdp, 'Ссылка · Верстак / Smoke Project', 'search: link result shows type and breadcrumbs') await clickText(cdp, '.global-search-result', 'Smoke note') await assertText(cdp, 'Rendered by GUI smoke.', 'search: note result opens note editor') await dispatchBodyKeydown(cdp, 'Backspace') await waitForGone(cdp, '.note-editor') 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, '.sync-actions .btn', 'Синхронизировать сейчас') await assertText(cdp, 'Конфликты: 1', 'settings: sync conflict warning visible') await assertText(cdp, 'Ошибки применения: 1', 'settings: sync apply error warning visible') 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 assertEval(cdp, `![...document.querySelectorAll('.tree-label')].some((el) => el.innerText.includes('Inbox Smoke Item'))`, 'workspace: captured item hidden from tree') await clickText(cdp, '.nav-item', 'Неразобранное') await assertText(cdp, 'Неразобранное', 'inbox: system view opens') await assertEval(cdp, `!document.querySelector('.inbox-header h2')`, 'inbox: screen title is not duplicated inside body') await assertText(cdp, 'Inbox Smoke Item', 'inbox: captured item visible') await assertText(cdp, 'Текст', 'inbox: capture kind visible') await assertText(cdp, 'Буфер обмена', 'inbox: capture source visible') await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('Manual Root Item')`, 'inbox: manual root is hidden') await setClipboardText(cdp, 'https://example.test/from-clipboard') await clickText(cdp, '.header-actions .btn', 'Вставить из буфера') await assertText(cdp, 'example.test', 'inbox: clipboard URL captured') await assertEval(cdp, ` (() => { const row = [...document.querySelectorAll('.inbox-item')].find((el) => el.innerText.includes('example.test')); if (!row) return false; if (row.innerText.includes('Ссылка') || row.innerText.includes('URL') || row.innerText.includes('capture.kind.url')) return true; throw new Error('URL row text: ' + row.innerText); })() `, 'inbox: clipboard URL kind visible') await assertInboxItemButtonAbsent(cdp, 'example.test', 'Показать в проводнике', 'inbox: link has no explorer action') await assertEval(cdp, ` (() => { const row = [...document.querySelectorAll('.inbox-item')].find((el) => el.innerText.includes('example.test')); if (!row) return false; const iconButtons = [...row.querySelectorAll('.inbox-icon-btn')]; return iconButtons.length >= 3 && iconButtons.every((btn) => btn.title && btn.innerText.trim() === ''); })() `, 'inbox: frequent actions are icon buttons with titles') await clickInboxItemButton(cdp, 'example.test', 'Открыть') await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://example.test/from-clipboard')`, 'inbox: open URL launches external URL') await clickInboxItemButton(cdp, 'example.test', 'Разложить') await waitForSelector(cdp, '.modal input[type="text"]') await setInputValue(cdp, '.modal input[type="text"]', 'Smoke') await waitForSelector(cdp, '.assign-search-result') await clickText(cdp, '.assign-search-result', 'Smoke Project') await clickText(cdp, '.modal-actions .btn', 'Разложить') await waitForGone(cdp, '.modal-overlay') await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox') await setClipboardText(cdp, 'mirv.top') await clickText(cdp, '.header-actions .btn', 'Вставить из буфера') await assertText(cdp, 'mirv.top', 'inbox: bare domain captured as URL') await clickInboxItemButton(cdp, 'mirv.top', 'Открыть') await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://mirv.top')`, 'inbox: bare domain opens normalized URL') await clickInboxItemButton(cdp, 'mirv.top', 'Удалить') await clickText(cdp, '.overlay .btn', 'Удалить') await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder']) await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured') await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible') await clickInboxItemButton(cdp, 'smoke-drop-folder', 'Открыть') await assertEval(cdp, `document.querySelector('.tab.active')?.innerText.includes('Файлы')`, 'inbox: open folder switches to files tab') await assertText(cdp, 'captured-folder-file.txt', 'inbox: open folder shows captured folder contents') await clickText(cdp, '.nav-item', 'Неразобранное') await dispatchPasteImage(cdp, 'pasted-smoke.png', 'image/png', 'c21va2UtaW1hZ2U=') await assertText(cdp, 'pasted-smoke.png', 'inbox: clipboard image captured') await assertText(cdp, 'Изображение', 'inbox: clipboard image kind visible') await clickInboxItemButton(cdp, 'smoke-drop-folder', 'Разложить') await waitForSelector(cdp, '.modal input[type="text"]') await setInputValue(cdp, '.modal input[type="text"]', 'Smoke') await waitForSelector(cdp, '.assign-search-result') await assertEval(cdp, `document.querySelector('.assign-search-result')?.innerText.includes('Smoke Project')`, 'inbox: assign target search works') await clickText(cdp, '.assign-search-result', 'Smoke Project') await clickText(cdp, '.modal-actions .btn', 'Разложить') await waitForGone(cdp, '.modal-overlay') await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('smoke-drop-folder')`, 'inbox: assigned item leaves inbox') await clickInboxItemButton(cdp, 'pasted-smoke.png', 'Открыть') await waitForSelector(cdp, '.preview-image') await assertText(cdp, 'pasted-smoke.png', 'inbox: open image shows preview') await click(cdp, '.action-btn-close') await clickInboxItemButton(cdp, 'pasted-smoke.png', 'Удалить') await clickText(cdp, '.overlay .btn', 'Удалить') await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('pasted-smoke.png')`, 'inbox: deleted item leaves inbox') await dispatchExternalDrag(cdp) await waitForSelector(cdp, '.capture-drop-overlay') await dispatchBodyKeydown(cdp, 'Escape') await waitForGone(cdp, '.capture-drop-overlay') await assertEval(cdp, `document.querySelector('.nav-item')?.click && !document.querySelector('.capture-drop-overlay')`, 'drag: Escape resets capture overlay') await dispatchExternalDrag(cdp) await waitForSelector(cdp, '.capture-drop-overlay') await dispatchWindowBlur(cdp) await waitForGone(cdp, '.capture-drop-overlay') await assertEval(cdp, `!document.querySelector('.capture-drop-overlay')`, 'drag: blur resets capture overlay') await screenshot(cdp, 'inbox.png') await clickText(cdp, '.inbox-item', 'Inbox Smoke Item') await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list') await clickText(cdp, '.note-editor-actions .btn', 'Закрыть') await clickText(cdp, '.nav-item', 'Корзина') await assertEval(cdp, `!document.querySelector('.trash-header h2')`, 'trash: screen title is not duplicated inside body') await assertText(cdp, 'Trash Smoke Folder', 'trash: deleted node visible') await assertEval(cdp, `!document.body.innerText.includes('.verstak/trash') && !document.body.innerText.includes('node-trash_Trash-Smoke-Folder')`, 'trash: physical implementation entries are hidden') await click(cdp, '.trash-row.folder .trash-row-icon') await assertText(cdp, 'trash-child.txt', 'trash: opening deleted folder shows children immediately') await assertText(cdp, 'Корзина / Trash Smoke Folder', 'trash: current folder breadcrumb visible') await dispatchBodyKeydown(cdp, 'Backspace') await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: Backspace returns to trash root') await click(cdp, '.trash-row.folder .trash-row-icon') await dispatchMouseBack(cdp) await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: mouse Back returns to trash root') await clickText(cdp, '.nav-item', 'Журнал') await clickText(cdp, '.nav-item', 'Корзина') await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: sidebar return uses stable root state') await screenshot(cdp, 'trash.png') 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 === 8`, 'node: all primary tabs rendered') await assertEval(cdp, `[...document.querySelectorAll('.tab')].map((el) => el.innerText.trim()).join('|') === 'Обзор|Заметки|Файлы|Ссылки|Действия|Активность|Журнал|Неразобранное'`, 'node: tabs use content-first order') await assertEval(cdp, `document.querySelector('.tab-separator') !== null`, 'node: service tabs are visually separated') await screenshot(cdp, 'node-overview.png') await clickText(cdp, '.tab', 'Заметки') await assertText(cdp, 'Smoke note', 'notes: existing note visible') await clickText(cdp, '.btn', '+ Добавить заметку') await waitForSelector(cdp, '.create-form input[type="text"]') await setInputValue(cdp, '.create-form input[type="text"]', 'GUI smoke note') await clickText(cdp, '.create-form .btn', 'Создать') await assertText(cdp, 'GUI smoke note', 'notes: created note appears') await clickText(cdp, '.tab', 'Файлы') await clickText(cdp, '.tab', 'Ссылки') await dispatchBodyKeydown(cdp, 'Backspace') await assertEval(cdp, `document.querySelector('.tab.active')?.innerText.includes('Файлы')`, 'navigation: Backspace returns to previous 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 click(cdp, '.back-btn') await assertText(cdp, 'brief.md', 'files: back button returns to parent folder') await clickText(cdp, '.tab', 'Ссылки') await assertText(cdp, 'example.test', 'links: resolved link visible') await clickText(cdp, '.tab', 'Действия') await assertText(cdp, 'Deploy smoke', 'actions: action card visible') await clickText(cdp, '.actions-tab .btn', 'Добавить действие') await waitForSelector(cdp, '.modal input[type="text"]') await dispatchKeydown(cdp, '.modal input[type="text"]', ' ') await assertEval(cdp, `!!document.querySelector('.modal input[type="text"]')`, 'actions: space in title input keeps modal open') await dispatchKeydown(cdp, '.modal input[type="text"]', 'Backspace') await assertEval(cdp, `!!document.querySelector('.modal input[type="text"]')`, 'navigation: Backspace in input does not navigate') await setInputValue(cdp, '.modal input[type="text"]', 'GUI smoke action with spaces') await setInputValue(cdp, '.modal input[placeholder="https://example.com"]', 'https://example.test/action') await clickText(cdp, '.modal-actions .btn', 'Создать') await waitForGone(cdp, '.modal-overlay') await assertText(cdp, 'GUI smoke action with spaces', 'actions: action with spaces saved') await clickText(cdp, '.tab', 'Журнал') await assertText(cdp, 'Manual smoke entry', 'worklog: entry visible') await clickText(cdp, '.worklog-tab-suggestions .suggestion-actions .btn', 'Изменить') await waitForSelector(cdp, '.modal-worklog') await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke accepted suggestion') await setInputValue(cdp, '.modal-worklog input[type="number"]', '35') await setInputValue(cdp, '.modal-worklog textarea', 'Accepted after manual edit') await clickText(cdp, '.modal-worklog .btn', 'Сохранить') await waitForGone(cdp, '.modal-worklog') await assertText(cdp, 'GUI smoke accepted suggestion', 'worklog: edited suggestion accepted') await clickText(cdp, '.worklog-toolbar .btn', 'Добавить запись') await waitForSelector(cdp, '.modal-worklog') await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog') await setInputValue(cdp, '.modal-worklog input[type="number"]', '15') await clickText(cdp, '.modal-worklog .btn', 'Сохранить') await waitForGone(cdp, '.modal-worklog') await assertText(cdp, 'GUI smoke worklog', 'worklog: created entry appears') await clickText(cdp, '.worklog-entry', 'GUI smoke worklog') await clickText(cdp, '.worklog-entry-actions .btn', 'Редактировать запись') await waitForSelector(cdp, '.modal-worklog') await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog edited') await setInputValue(cdp, '.modal-worklog input[type="number"]', '25') await clickText(cdp, '.modal-worklog .btn', 'Сохранить') await waitForGone(cdp, '.modal-worklog') await assertText(cdp, 'GUI smoke worklog edited', 'worklog: edited entry appears') await clickText(cdp, '.worklog-entry', 'GUI smoke worklog edited') await clickText(cdp, '.worklog-entry-actions .btn', 'Удалить запись') await clickText(cdp, '.overlay .btn', 'Удалить') await assertEval(cdp, `!document.body.innerText.includes('GUI smoke worklog edited')`, 'worklog: deleted entry disappears') await clickText(cdp, '.tab', 'Активность') await assertText(cdp, 'Smoke activity', 'activity: per-node activity visible') await emitDroppedFiles(cdp, ['/tmp/project-context-file.txt']) await clickText(cdp, '.nav-item', 'Неразобранное') await assertText(cdp, 'project-context-file.txt', 'inbox: node-context capture visible globally') await assertText(cdp, 'Захвачено в: Smoke Project', 'inbox: global list shows capture context') await assertText(cdp, 'Цель: Smoke Project', 'inbox: global list shows suggested target') await clickInboxItemButton(cdp, 'project-context-file.txt', 'Удалить') await clickText(cdp, '.overlay .btn', 'Удалить') await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('project-context-file.txt')`, 'inbox: node-context capture can be deleted') await clickText(cdp, '.nav-item', 'Сегодня') await assertText(cdp, 'Сегодня', 'today: system view opens') await assertEval(cdp, `!document.querySelector('.today-header h2')`, 'today: screen title is not duplicated inside body') await assertText(cdp, 'Smoke Project', 'today: dashboard data visible') await clickText(cdp, '.nav-item', 'Журнал') await waitForSelector(cdp, '.journal-screen') await assertEval(cdp, `!document.querySelector('.journal-screen h2')`, 'journal: screen title is not duplicated inside body') await clickText(cdp, '.journal-tab', 'Журнал работы') await screenshot(cdp, 'journal.png') await assertEval(cdp, ` (() => { const summary = document.querySelector('.journal-summary'); const exportBlock = document.querySelector('.journal-export-section'); const filterBlock = document.querySelector('.journal-filter-section'); const table = document.querySelector('.journal-table-wrap'); if (!summary || !exportBlock || !filterBlock || !table) return false; const top = (el) => el.getBoundingClientRect().top; return top(summary) < top(exportBlock) && top(exportBlock) < top(filterBlock) && top(filterBlock) < top(table); })() `, 'journal: summary, export, filters, work list order') 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 assertEval(cdp, `!document.querySelector('.activity-feed-header h2')`, 'activity: screen title is not duplicated inside body') await assertText(cdp, 'Smoke activity', 'activity feed: event visible') await clickText(cdp, '.nav-item', 'Сегодня') await clickText(cdp, '.tree-label', 'Smoke Client') await clickText(cdp, '.tree-label', 'Smoke Project') await deleteSmokeNode(cdp, 'node-client') await dispatchBodyKeydown(cdp, 'Backspace') await assertEval(cdp, `document.querySelector('.header-title')?.innerText.trim() === 'Сегодня'`, 'navigation: Backspace skips deleted history entries') await click(cdp, '.nav-add-btn') await waitForSelector(cdp, '.modal-create') await assertText(cdp, 'Создать элемент', 'create node: modal opens') await clickText(cdp, '.template-card', 'Пустое дело') await setInputValue(cdp, '.modal-create input[type="text"]', 'GUI Smoke Created') await screenshot(cdp, 'create-node-modal.png') await clickText(cdp, '.modal-actions .btn', 'Создать') await waitForGone(cdp, '.modal-create') await assertText(cdp, 'GUI Smoke Created', 'create node: created node appears') 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, inbox, trash, 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 setInputValue(cdp, selector, value) { const ok = await evalValue(cdp, ` (() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.focus(); el.value = ${JSON.stringify(value)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; })() `) if (!ok) throw new Error(`Input target not found: ${selector}`) await sleep(100) } async function dispatchKeydown(cdp, selector, key) { const ok = await evalValue(cdp, ` (() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.focus(); const event = new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true, cancelable: true }); el.dispatchEvent(event); return true; })() `) if (!ok) throw new Error(`Keydown target not found: ${selector}`) await sleep(100) } async function dispatchBodyKeydown(cdp, key) { await cdp.send('Runtime.evaluate', { expression: ` (() => { document.body.focus(); const event = new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true, cancelable: true }); window.dispatchEvent(event); })() `, returnByValue: true, }) await sleep(150) } async function dispatchShortcut(cdp, key, ctrlKey = false) { await cdp.send('Runtime.evaluate', { expression: ` (() => { const event = new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, ctrlKey: ${ctrlKey ? 'true' : 'false'}, bubbles: true, cancelable: true, }); window.dispatchEvent(event); })() `, returnByValue: true, }) await sleep(150) } async function dispatchMouseBack(cdp) { await cdp.send('Runtime.evaluate', { expression: ` (() => { const event = new MouseEvent('auxclick', { button: 3, bubbles: true, cancelable: true }); window.dispatchEvent(event); })() `, returnByValue: true, }) await sleep(150) } async function deleteSmokeNode(cdp, id) { await cdp.send('Runtime.evaluate', { expression: `window.__VERSTAK_GUI_SMOKE__.deleteNode(${JSON.stringify(id)})`, awaitPromise: true, returnByValue: true, }) await sleep(150) } async function setClipboardText(cdp, value) { await cdp.send('Runtime.evaluate', { expression: `window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ = ${JSON.stringify(value)}`, returnByValue: true, }) } async function setClipboardImage(cdp, name, type, base64) { await cdp.send('Runtime.evaluate', { expression: ` window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ = ''; window.__VERSTAK_GUI_SMOKE_CLIPBOARD_ITEMS__ = [{ types: [${JSON.stringify(type)}], getType: async () => new File([Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0))], ${JSON.stringify(name)}, { type: ${JSON.stringify(type)} }), }]; `, awaitPromise: true, returnByValue: true, }) } async function dispatchPasteImage(cdp, name, type, base64) { await cdp.send('Runtime.evaluate', { expression: ` (() => { const bytes = Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0)); const file = new File([bytes], ${JSON.stringify(name)}, { type: ${JSON.stringify(type)} }); const data = new DataTransfer(); data.items.add(file); const event = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }); window.dispatchEvent(event); })(); `, awaitPromise: true, returnByValue: true, }) await sleep(300) } async function emitDroppedFiles(cdp, paths) { await cdp.send('Runtime.evaluate', { expression: `window.__VERSTAK_GUI_SMOKE__.dropFiles(${JSON.stringify(paths)})`, awaitPromise: true, returnByValue: true, }) await sleep(300) } async function dispatchExternalDrag(cdp) { await cdp.send('Runtime.evaluate', { expression: ` (() => { const data = new DataTransfer(); data.items.add(new File(['smoke'], 'drag-smoke.txt', { type: 'text/plain' })); const enter = new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: data }); const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: data }); window.dispatchEvent(enter); window.dispatchEvent(over); })() `, awaitPromise: true, returnByValue: true, }) await sleep(150) } async function dispatchWindowBlur(cdp) { await cdp.send('Runtime.evaluate', { expression: `window.dispatchEvent(new Event('blur'))`, returnByValue: true, }) await sleep(150) } 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 clickInboxItemButton(cdp, title, buttonText) { const ok = await evalValue(cdp, ` (() => { const norm = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const row = [...document.querySelectorAll('.inbox-item')] .find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(title)})); if (!row) return false; const btn = [...row.querySelectorAll('button')] .find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(buttonText)}) || norm(node.getAttribute('title')).includes(${JSON.stringify(buttonText)}) || norm(node.getAttribute('aria-label')).includes(${JSON.stringify(buttonText)})); if (!btn) return false; btn.click(); return true; })() `) if (!ok) throw new Error(`Inbox item button not found: ${title} -> ${buttonText}`) await sleep(250) } async function assertInboxItemButtonAbsent(cdp, title, buttonText, label) { await assertEval(cdp, ` (() => { const norm = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const row = [...document.querySelectorAll('.inbox-item')] .find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(title)})); if (!row) return false; return ![...row.querySelectorAll('button')] .some((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(buttonText)}) || norm(node.getAttribute('title')).includes(${JSON.stringify(buttonText)}) || norm(node.getAttribute('aria-label')).includes(${JSON.stringify(buttonText)})); })() `, label) } async function evalValue(cdp, expression) { const result = await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, userGesture: true, }) if (result.exceptionDetails) { const detail = result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'Runtime.evaluate exception' throw new Error(detail) } 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'; Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '', read: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD_ITEMS__ || [], }, }); 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: [] }, { id: 'node-inbox', title: 'Inbox Smoke Item', type: 'folder', section: '', captureInbox: true, captureKind: 'text', captureSource: 'clipboard', createdAt: now, has_children: false, children: [] }, { id: 'node-manual-root', title: 'Manual Root Item', type: 'folder', section: '', 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' }], }, links: { 'node-project': [], }, trashNodes: [ { id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', nodePath: 'Smoke / Trash Smoke Folder', deletedAt: now }, { id: 'node-trash-child', parentId: 'node-trash', title: 'trash-child.txt', type: 'file', fsPath: 'Trash Smoke Folder/trash-child.txt', nodePath: 'Smoke / Trash Smoke Folder / trash-child.txt', deletedAt: now }, { id: 'node-trash-nested', parentId: 'node-trash', title: 'Nested Trash Folder', type: 'folder', fsPath: 'Trash Smoke Folder/Nested Trash Folder', nodePath: 'Smoke / Trash Smoke Folder / Nested Trash Folder', deletedAt: now }, ], openedUrls: [], }; const fileNodeDetails = { 'note-1': { id: 'note-1', title: 'Smoke note', type: 'note', parent_id: 'node-project', createdAt: now, has_children: false }, '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 parseCaptureContext(contextJSON) { let raw = {}; try { raw = JSON.parse(contextJSON || '{}') || {}; } catch {} const out = {}; if (raw.contextType === 'node' && raw.nodeId) { out.captureContextType = 'node'; out.captureContextNodeId = raw.nodeId; out.suggestedTargetNodeId = raw.suggestedTargetNodeId || raw.nodeId; out.captureContextLabel = findNode(raw.nodeId)?.title || raw.nodeId; out.suggestedTargetLabel = findNode(out.suggestedTargetNodeId)?.title || out.suggestedTargetNodeId; } else if (raw.contextType === 'section') { out.captureContextType = 'section'; out.captureContextSection = raw.section || 'root'; out.captureContextLabel = out.captureContextSection; } else { out.captureContextType = 'global'; out.captureContextSection = raw.section || 'root'; } return out; } function hostnameForURL(value) { try { return new URL(value).hostname; } catch { return ''; } } function normalizeURL(value) { const raw = String(value || '').trim(); if (!raw || /[\\s]/.test(raw) || raw.includes('@')) return ''; try { const parsed = new URL(raw); return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : ''; } catch { try { const withScheme = 'https://' + raw; const parsed = new URL(withScheme); return parsed.hostname && parsed.hostname.includes('.') ? withScheme : ''; } catch { return ''; } } } function inboxDTO(node) { return { ...node, captureKind: node.captureKind || '', sourceKind: node.sourceKind || node.captureKind || '', captureSource: node.captureSource || '', captureStatus: node.captureStatus || 'unresolved', captureContextType: node.captureContextType || 'global', captureContextNodeId: node.captureContextNodeId || '', captureContextSection: node.captureContextSection || '', suggestedTargetNodeId: node.suggestedTargetNodeId || '', captureContextLabel: node.captureContextLabel || '', suggestedTargetLabel: node.suggestedTargetLabel || '', capturedAt: node.capturedAt || node.createdAt || '', url: node.url || '', hostname: node.hostname || '', }; } function detachNode(id, items = state.nodes) { const idx = items.findIndex((node) => node.id === id); if (idx >= 0) { const [node] = items.splice(idx, 1); return node; } for (const item of items) { if (!item.children) continue; const found = detachNode(id, item.children); if (found) return found; } return null; } 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: 'trash', label: 'Корзина' }, { id: 'activity', label: 'Активность' }, { id: 'journal', label: 'Журнал' }, ], ListWorkspaceTree: async () => clone(state.nodes.filter((node) => node.captureInbox !== true)), ListWorkspaceChildren: async (id) => clone(childrenOf(id)), ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && node.captureInbox === true).map(inboxDTO)), ListInboxNodesForTarget: async (nodeId) => clone(state.nodes.filter((node) => node.captureInbox === true && (node.captureContextNodeId === nodeId || node.suggestedTargetNodeId === nodeId)).map(inboxDTO)), CaptureTextWithContext: async (text, source, contextJSON) => { const ctx = parseCaptureContext(contextJSON); const node = { id: 'node-capture-text-' + Date.now(), title: String(text || '').trim().split('\\n').find(Boolean) || 'Captured text', type: 'note', section: '', captureInbox: true, captureKind: 'text', sourceKind: 'text', captureSource: source || 'paste', captureStatus: 'unresolved', createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx }; state.nodes.push(node); return clone(inboxDTO(node)); }, CaptureText: async (text) => App.CaptureTextWithContext(text, 'clipboard', '{}'), CaptureURLWithContext: async (url, title, source, contextJSON) => { const ctx = parseCaptureContext(contextJSON); const hostname = hostnameForURL(url); const node = { id: 'node-capture-url-' + Date.now(), title: title || hostname || url, type: 'link', section: '', captureInbox: true, captureKind: 'url', sourceKind: 'url', captureSource: source || 'paste', captureStatus: 'unresolved', url, hostname, createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx }; state.nodes.push(node); return clone(inboxDTO(node)); }, CaptureURL: async (url, title) => App.CaptureURLWithContext(url, title, 'clipboard', '{}'), CapturePathWithContext: async (sourcePath, source, contextJSON) => { const ctx = parseCaptureContext(contextJSON); const title = String(sourcePath || '').split('/').filter(Boolean).pop() || 'Dropped file'; const kind = title.includes('folder') ? 'folder' : 'file'; const node = { id: 'node-capture-path-' + Date.now(), title, type: kind === 'folder' ? 'folder' : 'file', section: '', captureInbox: true, captureKind: kind, sourceKind: kind, captureSource: source || 'drop', captureStatus: 'unresolved', createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx }; if (kind === 'folder') { state.files[node.id] = [{ id: 'captured-folder-file', nodeId: 'captured-folder-file', fileId: 'captured-folder-file', name: 'captured-folder-file.txt', type: 'file', size: 42, createdAt: now }]; } else { state.files[node.id] = [{ id: node.id, nodeId: node.id, fileId: node.id, name: title, type: 'file', size: 42, createdAt: now }]; } state.nodes.push(node); return clone(inboxDTO(node)); }, CapturePath: async (sourcePath) => App.CapturePathWithContext(sourcePath, 'drop', '{}'), CaptureFileDataWithContext: async (filename, _dataBase64, source, contextJSON) => { const ctx = parseCaptureContext(contextJSON); const node = { id: 'node-capture-data-' + Date.now(), title: filename, type: 'file', section: '', captureInbox: true, captureKind: filename.endsWith('.png') ? 'image' : 'file', sourceKind: filename.endsWith('.png') ? 'image' : 'file', captureSource: source || 'paste', captureStatus: 'unresolved', createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx }; state.files[node.id] = [{ id: node.id, nodeId: node.id, fileId: node.id, name: filename, type: 'file', size: 32, mime: filename.endsWith('.png') ? 'image/png' : 'text/plain', createdAt: now }]; state.nodes.push(node); return clone(inboxDTO(node)); }, CaptureFileData: async (filename, dataBase64) => App.CaptureFileDataWithContext(filename, dataBase64, 'clipboard', '{}'), ReadClipboardText: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '', CaptureClipboardTextWithContext: async (contextJSON) => { const text = String(window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '').trim(); if (!text) throw new Error('clipboard is empty'); const normalizedURL = normalizeURL(text); if (normalizedURL) return App.CaptureURLWithContext(normalizedURL, '', 'clipboard_button', contextJSON); return App.CaptureTextWithContext(text, 'clipboard_button', contextJSON); }, ResolveInboxNode: async (nodeId, targetParentId) => { const node = detachNode(nodeId); const parent = findNode(targetParentId); if (!node || !parent) throw new Error('assign target not found'); if (node.sourceKind === 'url' || node.captureKind === 'url') { state.links[targetParentId] = state.links[targetParentId] || []; state.links[targetParentId].push({ id: 'link-' + Date.now(), nodeId: targetParentId, title: node.title, url: node.url, hostname: node.hostname || hostnameForURL(node.url), note: '', source: node.captureSource, capturedAt: node.capturedAt || now, createdAt: now, updatedAt: now }); return clone(parent); } node.captureInbox = false; node.captureKind = ''; node.captureSource = ''; node.parent_id = targetParentId; parent.children = parent.children || []; parent.children.push(node); parent.has_children = true; return clone(node); }, ResolveInboxNodeHere: async (nodeId) => { const node = findNode(nodeId); if (!node?.suggestedTargetNodeId) throw new Error('suggested target is required'); return App.ResolveInboxNode(nodeId, node.suggestedTargetNodeId); }, AssignInboxNode: async (nodeId, targetParentId) => App.ResolveInboxNode(nodeId, targetParentId), DeleteInboxNode: async (nodeId) => { const node = detachNode(nodeId); if (!node) throw new Error('inbox node not found'); return true; }, ListLinks: async (nodeId) => clone(state.links[nodeId] || []), UpdateLink: async (id, title, url, note) => { const list = Object.values(state.links).flat(); const link = list.find((item) => item.id === id); if (!link) throw new Error('link not found'); Object.assign(link, { title, url, note, hostname: hostnameForURL(url), updatedAt: now }); return clone(link); }, DeleteLink: async (id) => { for (const key of Object.keys(state.links)) { state.links[key] = state.links[key].filter((link) => link.id !== id); } return true; }, OpenLink: async () => true, OpenURL: async (url) => { state.openedUrls.push(url); return true }, ListTrash: async () => clone({ trashPath: '/tmp/verstak-smoke-vault/.verstak/trash', count: state.trashNodes.length, nodes: state.trashNodes, entries: [{ name: 'node-trash_Trash-Smoke-Folder', path: '/tmp/verstak-smoke-vault/.verstak/trash/node-trash_Trash-Smoke-Folder', isDir: true, size: 0, modifiedAt: now }], }), TrashCount: async () => state.trashNodes.length, RestoreTrashNodesJSON: async (idsJSON) => { const ids = JSON.parse(idsJSON || '[]'); state.trashNodes = state.trashNodes.filter((node) => !ids.includes(node.id)); return true; }, PurgeTrashNodesJSON: async (idsJSON) => { const ids = JSON.parse(idsJSON || '[]'); state.trashNodes = state.trashNodes.filter((node) => !ids.includes(node.id) && !ids.includes(node.parentId)); return true; }, EmptyTrash: async () => { state.trashNodes = []; return true }, OpenTrashFolder: async () => true, 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 || '', Search: async (query) => { const q = String(query || '').toLowerCase(); return [ { nodeId: 'node-project', title: 'Smoke Project', type: 'project', path: 'Верстак / Smoke Project', snippet: '' }, { nodeId: 'note-1', title: 'Smoke note', type: 'note', path: 'Верстак / Smoke Project', snippet: 'Rendered by GUI smoke.' }, { nodeId: 'file-brief', title: 'brief.md', type: 'file', path: 'Верстак / Smoke Project / Assets', snippet: '' }, { nodeId: 'link-smoke', title: 'example.test', type: 'link', path: 'Верстак / Smoke Project', snippet: 'https://example.test' }, ].filter((item) => (item.title + ' ' + item.snippet + ' ' + item.path).toLowerCase().includes(q)); }, 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, type: node.type })), CreateNodeFromTemplate: async (parentId, title, templateId) => { const id = 'node-created-' + Date.now(); const parent = parentId ? findNode(parentId) : null; const node = { id, title, type: templateId?.split('.')[0] || 'folder', section: parent?.section || '', parent_id: parentId || '', createdAt: now, has_children: false, children: [] }; if (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 () => 'data:image/png;base64,c21va2UtaW1hZ2U=', 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); }, UpdateWorklogEntry: async (id, summary, details, date, minutes, approximate, billable) => { for (const nodeId of Object.keys(state.worklog)) { const idx = state.worklog[nodeId].findIndex((entry) => entry.id === id); if (idx >= 0) { const updated = { ...state.worklog[nodeId][idx], summary, details, date, minutes, approximate, billable, updatedAt: now }; state.worklog[nodeId][idx] = updated; return clone(updated); } } throw new Error('worklog entry not found'); }, DeleteWorklogEntry: async (id) => { for (const nodeId of Object.keys(state.worklog)) { state.worklog[nodeId] = state.worklog[nodeId].filter((entry) => entry.id !== id); } return true; }, 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, AcceptSuggestionFull: async (nodeId, summary, details, date, minutes, approximate, billable) => { const entry = { id: 'wl-suggestion-' + Date.now(), nodeId, summary, details, date: date || '2026-06-04', minutes, approximate, billable, source: 'suggestion', createdAt: now }; state.worklog[nodeId] = [...(state.worklog[nodeId] || []), entry]; return clone(entry); }, 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: true, serverUrl: 'https://sync.example.test', deviceId: 'smoke-device', deviceName: 'Smoke Device', connected: true, revoked: false, unpushedOps: 0 }), GetSyncSettings: async () => ({ enabled: true, serverUrl: 'https://sync.example.test', deviceId: 'smoke-device', deviceName: 'Smoke Device', syncInterval: 0, lastStatus: 'connected' }), SyncNow: async () => ({ pushed: 0, pulled: 0, conflicts: [{ entityId: 'node-project' }], applyErrors: [{ opId: 'remote-op-1', error: 'smoke apply error' }] }), 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 } }; const runtimeHandlers = {}; window.runtime = { EventsOn: (name, handler) => { runtimeHandlers[name] = handler; }, EventsOff: (name) => { delete runtimeHandlers[name]; }, }; window.__VERSTAK_GUI_SMOKE__ = { mode, state, events, dropFiles: async (paths) => { if (runtimeHandlers['files-dropped']) await runtimeHandlers['files-dropped'](paths); }, deleteNode: async (id) => { detachNode(id); }, }; })(); ` }