1120 lines
48 KiB
JavaScript
Executable File
1120 lines
48 KiB
JavaScript
Executable File
#!/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 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 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, '.inbox-header .btn', 'Вставить из буфера')
|
|
await assertText(cdp, 'example.test', 'inbox: clipboard URL captured')
|
|
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
|
|
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 emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
|
|
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
|
|
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
|
|
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 clickText(cdp, '.overlay .btn', 'Удалить')
|
|
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('pasted-smoke.png')`, 'inbox: deleted item leaves inbox')
|
|
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, '.nav-item', 'Корзина')
|
|
await assertText(cdp, 'Trash Smoke Folder', 'trash: deleted node visible')
|
|
await assertText(cdp, 'node-trash_Trash-Smoke-Folder', 'trash: physical entry visible')
|
|
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 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 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 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 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 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 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 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)}));
|
|
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 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';
|
|
|
|
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': [],
|
|
},
|
|
};
|
|
|
|
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 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 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 };
|
|
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.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');
|
|
if (/^https?:\\/\\//.test(text)) return App.CaptureURLWithContext(text, '', '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,
|
|
ListTrash: async () => clone({
|
|
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
|
|
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],
|
|
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 }],
|
|
}),
|
|
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 || '',
|
|
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 () => '',
|
|
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);
|
|
},
|
|
};
|
|
})();
|
|
`
|
|
}
|