| 1 | // Real Electron + Go service, disposable storage, no provider invocation. |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; |
| 4 | import { tmpdir } from 'node:os'; |
| 5 | import { resolve, join } from 'node:path'; |
| 6 | import { fileURLToPath } from 'node:url'; |
| 7 | import { _electron } from 'playwright'; |
| 8 | |
| 9 | const root = resolve(fileURLToPath(new URL('..', import.meta.url))); |
| 10 | const home = mkdtempSync(join(tmpdir(), 'reasonix-draft-native-')); |
| 11 | const workspace = join(home, 'workspace'); |
| 12 | mkdirSync(workspace); |
| 13 | const report = { home, checks: [] }; |
| 14 | const check = name => { report.checks.push(name); console.log(`PASS ${name}`); }; |
| 15 | const launch = () => _electron.launch({ args: [root], env: { ...process.env, |
| 16 | REASONIX_HOME: home, REASONIX_STATE_HOME: home, REASONIX_CACHE_HOME: join(home,'cache'), |
| 17 | REASONIX_DEV: '1', REASONIX_DESKTOP_SERVICE: resolve(root,'../build/bin/reasonix-desktop-service') }, timeout:60000 }); |
| 18 | async function ready(app) { |
| 19 | const page = await app.firstWindow(); |
| 20 | await page.waitForFunction(() => Boolean(window.reasonixDesktop) && !document.querySelector('.boot-shell'), null, { timeout:60000 }); |
| 21 | await page.waitForFunction(async () => { try { await window.reasonixDesktop.invoke('ListTabs', []); return true; } catch { return false; } }, null, { timeout:60000 }); |
| 22 | return page; |
| 23 | } |
| 24 | const invoke = (page, name, args=[]) => page.evaluate(({name,args}) => window.reasonixDesktop.invoke(name,args),{name,args}); |
| 25 | const installClipboardFixture = shell => shell.evaluate(async ({clipboard,ClipboardItem}) => { |
| 26 | globalThis.__draftSmokeClipboard = await Promise.all((await clipboard.read()).map(async item => new ClipboardItem(Object.fromEntries(await Promise.all(item.types.map(async type => [type, await item.getType(type)])))))); |
| 27 | const png = new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aY1sAAAAASUVORK5CYII=','base64')],{type:'image/png'}); |
| 28 | // Explicit PNG matches the OS screenshot flavor consumed by the service. |
| 29 | await clipboard.write([new ClipboardItem({'electron application/osclipboard;format="public.png"': png})]); |
| 30 | }); |
| 31 | const restoreClipboard = shell => shell.evaluate(async ({clipboard}) => { |
| 32 | await clipboard.write(globalThis.__draftSmokeClipboard); |
| 33 | delete globalThis.__draftSmokeClipboard; |
| 34 | }); |
| 35 | let shell; |
| 36 | try { |
| 37 | shell = await launch(); |
| 38 | let page = await ready(shell); |
| 39 | const before = await invoke(page,'ListTabs'); |
| 40 | const drafts = await Promise.all(Array.from({length:20}, () => invoke(page,'OpenSessionDraftForTarget',['project',workspace]))); |
| 41 | assert.equal(new Set(drafts.map(draft=>draft.id)).size,1); |
| 42 | assert.equal((await invoke(page,'ListTabs')).length,before.length); |
| 43 | check('20 native RPC opens reuse one draft without creating a runtime tab'); |
| 44 | let draft = drafts[0]; |
| 45 | // Exercise the native clipboard service, keeping the explicit draft target. |
| 46 | await installClipboardFixture(shell); |
| 47 | let attachment; |
| 48 | try { attachment = await invoke(page,'SaveClipboardImageForTarget',[{kind:'draft',draftId:draft.id}]); } |
| 49 | finally { await restoreClipboard(shell); } |
| 50 | assert.ok(attachment); |
| 51 | const content = { text:'native restart fixture', attachments:[{path:attachment}], invocations:[], workspaceRefs:[], sessionRefs:[], selectedTextRefs:[], pastedBlocks:[], openPastedLabels:[] }; |
| 52 | const saved = await invoke(page,'SaveSessionDraft',[{draftId:draft.id,revision:draft.revision,contentJson:JSON.stringify(content),settings:draft.settings}]); |
| 53 | assert.equal(saved.outcome,'saved'); |
| 54 | assert.ok(saved.draft.snapshotDigest); |
| 55 | draft = saved.draft; |
| 56 | await invoke(page,'SetSessionDraftRestoreTarget',[draft.id]); |
| 57 | check('native clipboard attachment is saved to the captured workspace with a confirmed digest'); |
| 58 | await page.evaluate(() => window.__reasonixFlushSessionDraft?.()); |
| 59 | await shell.close(); |
| 60 | shell = await launch(); |
| 61 | page = await ready(shell); |
| 62 | const restored = await invoke(page,'GetSessionDraftState',[draft.id]); |
| 63 | assert.equal(restored.draft.id,draft.id); |
| 64 | assert.equal(JSON.parse(restored.draft.contentJson).text,content.text); |
| 65 | assert.equal(JSON.parse(restored.draft.contentJson).attachments[0].path,attachment); |
| 66 | const preview = await invoke(page,'AttachmentDataURLForTarget',[{kind:'draft',draftId:draft.id},attachment]); |
| 67 | assert.match(preview,/^data:image\//); |
| 68 | assert.equal((await invoke(page,'ListTabs')).length,before.length); |
| 69 | check('normal Electron restart restores text and native attachment without creating a session'); |
| 70 | const composer = page.locator('textarea.composer__input:not([aria-hidden=true])'); |
| 71 | await composer.waitFor({state:'visible'}); |
| 72 | const droppedPath = join(home, 'native-drop-fixture.txt'); |
| 73 | writeFileSync(droppedPath, 'disposable native file drop fixture'); |
| 74 | const bounds = await page.locator('[data-native-drop-target]').first().boundingBox(); |
| 75 | assert.ok(bounds); |
| 76 | const cdp = await page.context().newCDPSession(page); |
| 77 | const drag = {x:bounds.x+bounds.width/2,y:bounds.y+bounds.height/2,data:{items:[],files:[droppedPath],dragOperationsMask:1}}; |
| 78 | await cdp.send('Input.dispatchDragEvent',{type:'dragEnter',...drag}); |
| 79 | await cdp.send('Input.dispatchDragEvent',{type:'dragOver',...drag}); |
| 80 | await cdp.send('Input.dispatchDragEvent',{type:'drop',...drag}); |
| 81 | await page.waitForFunction(async id => { |
| 82 | const state = await window.reasonixDesktop.invoke('GetSessionDraftState',[id]); |
| 83 | const content = JSON.parse(state.draft.contentJson); |
| 84 | return content.attachments.length > 1 || content.workspaceRefs.length > 0; |
| 85 | }, draft.id); |
| 86 | await cdp.detach(); |
| 87 | check('native file drop crosses the preload boundary and persists in its draft'); |
| 88 | const beforePaste = JSON.parse((await invoke(page,'GetSessionDraftState',[draft.id])).draft.contentJson).attachments.length; |
| 89 | await installClipboardFixture(shell); |
| 90 | try { |
| 91 | await composer.focus(); |
| 92 | await page.keyboard.press('Meta+v'); |
| 93 | await page.waitForFunction(async ({id,count}) => { |
| 94 | const state = await window.reasonixDesktop.invoke('GetSessionDraftState',[id]); |
| 95 | return JSON.parse(state.draft.contentJson).attachments.length > count; |
| 96 | }, {id:draft.id,count:beforePaste}); |
| 97 | } finally { await restoreClipboard(shell); } |
| 98 | check('native keyboard paste persists through the Composer task owner'); |
| 99 | await composer.fill('native close flushes the latest editor version'); |
| 100 | // Close immediately, without waiting for the debounce or manually flushing. |
| 101 | await shell.close(); |
| 102 | shell = await launch(); |
| 103 | page = await ready(shell); |
| 104 | draft = (await invoke(page,'GetSessionDraftState',[draft.id])).draft; |
| 105 | assert.equal(JSON.parse(draft.contentJson).text,'native close flushes the latest editor version'); |
| 106 | check('native renderer edit survives immediate normal close through the shutdown barrier'); |
| 107 | // Kill the actual Electron process after confirmed persistence. The isolated |
| 108 | // service must recover through its ordinary parent/pipe shutdown path. |
| 109 | const stopped = shell.waitForEvent('close'); |
| 110 | shell.process().kill('SIGKILL'); |
| 111 | await stopped; |
| 112 | shell = await launch(); |
| 113 | page = await ready(shell); |
| 114 | const recovered = await invoke(page,'GetSessionDraftState',[draft.id]); |
| 115 | assert.equal(recovered.draft.contentJson,draft.contentJson); |
| 116 | check('confirmed data survives Electron SIGKILL and service restart without replay'); |
| 117 | } finally { |
| 118 | if(shell) await shell.close().catch(()=>{}); |
| 119 | writeFileSync(join(home,'draft-smoke-results.json'),JSON.stringify(report,null,2)); |
| 120 | console.log(`Evidence: ${join(home,'draft-smoke-results.json')}`); |
| 121 | } |
| 122 |