| 1 | // Production macOS package: original JSONL/event history stays readable when |
| 2 | // an external writer prevents execution recovery. Fixtures never call a model. |
| 3 | // Usage: node desktop/packaging/history-native-smoke.mjs /path/Reasonix.app |
| 4 | import assert from "node:assert/strict"; |
| 5 | import { spawn } from "node:child_process"; |
| 6 | import { once } from "node:events"; |
| 7 | import { createRequire } from "node:module"; |
| 8 | import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; |
| 9 | import { join, resolve } from "node:path"; |
| 10 | import { tmpdir } from "node:os"; |
| 11 | import { packagedSmokeEnv } from "./smoke-env.mjs"; |
| 12 | import { parseServiceReady, waitForSmokeCondition } from "./smoke-poll.mjs"; |
| 13 | import { closeAndVerify } from "./smoke-lifecycle.mjs"; |
| 14 | |
| 15 | assert.equal(process.platform, "darwin", "This fixture exercises the macOS production bundle"); |
| 16 | const require = createRequire(new URL("../electron/package.json", import.meta.url)); |
| 17 | const { _electron } = require("playwright"); |
| 18 | const bundle = resolve(process.argv[2]); |
| 19 | const home = mkdtempSync(join(tmpdir(), "reasonix-native-history-")); |
| 20 | const dir = join(home, "sessions"); |
| 21 | mkdirSync(dir); |
| 22 | writeFileSync(join(home, "config.toml"), `default_model = "fixture/model"\n[desktop]\nprovider_access = ["fixture"]\n[[providers]]\nname = "fixture"\nkind = "openai"\nbase_url = "http://127.0.0.1:1/v1"\nmodels = ["model"]\ndefault = "model"\napi_key_env = "HISTORY_FIXTURE_KEY"\n`); |
| 23 | const answer = "完整内容🧭".repeat(90000); |
| 24 | const fixtures = ["checkpoint", "events"].map(kind => { |
| 25 | const messages = Array.from({ length: 140 }, (_, index) => [ |
| 26 | { role: "user", content: `NATIVE_${kind}_QUESTION_${index}` }, |
| 27 | { role: "assistant", content: index === 139 ? answer : `NATIVE_${kind}_ANSWER_${index}` }, |
| 28 | ]).flat(); |
| 29 | const path = join(dir, `${kind}.jsonl`); |
| 30 | const eventPath = join(dir, `${kind}.events.jsonl`); |
| 31 | const body = kind === "checkpoint" ? messages.map(message => JSON.stringify(message)).join("\n") + "\n" |
| 32 | : JSON.stringify({ role: "user", content: "OBSOLETE_CHECKPOINT" }) + "\n"; |
| 33 | writeFileSync(path, body); |
| 34 | const events = JSON.stringify({ messages, type: "replace", schema_version: 1 }) + "\n"; |
| 35 | if (kind === "events") writeFileSync(eventPath, events); |
| 36 | return { kind, path, eventPath, body, events }; |
| 37 | }); |
| 38 | let application, lease, page; |
| 39 | let passed = false; |
| 40 | try { |
| 41 | // The OS lock is the real writer fence. No service/runtime test hook is used. |
| 42 | lease = spawn("python3", ["-u", "-c", `import fcntl, sys |
| 43 | locks = [open(path, 'a+') for path in sys.argv[1:]] |
| 44 | for file in locks: fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB) |
| 45 | print('locked', flush=True) |
| 46 | sys.stdin.read() |
| 47 | `, ...fixtures.map(fixture => fixture.path + ".lease.lock")], { stdio: ["pipe", "pipe", "inherit"] }); |
| 48 | await Promise.race([once(lease.stdout, "data").then(([value]) => assert.match(String(value), /locked/)), |
| 49 | once(lease, "exit").then(() => { throw new Error("fixture writer exited before lock acquisition"); })]); |
| 50 | application = await _electron.launch({ executablePath: join(bundle, "Contents/MacOS/Reasonix"), |
| 51 | env: { ...packagedSmokeEnv(process.env, home), HISTORY_FIXTURE_KEY: "local-fixture" } }); |
| 52 | await waitForSmokeCondition(async () => { |
| 53 | for (const candidate of application.windows()) { |
| 54 | if (await candidate.evaluate(() => Boolean(window.reasonixDesktop)).catch(() => false)) { page = candidate; return true; } |
| 55 | } |
| 56 | return false; |
| 57 | }); |
| 58 | const invoke = (method, args = []) => page.evaluate(({ method, args }) => window.reasonixDesktop.invoke(method, args), { method, args }); |
| 59 | const build = JSON.parse(readFileSync(join(bundle, "Contents/Resources/build.json"), "utf8")); |
| 60 | assert.equal(await invoke("Version"), build.version); |
| 61 | assert.equal(await application.evaluate(({ app }) => app.isPackaged && !process.env.REASONIX_DEV), true); |
| 62 | const folder = page.locator(".project-tree__folder-main").first(); |
| 63 | await folder.waitFor(); |
| 64 | if (await folder.getAttribute("aria-expanded") !== "true") await folder.click(); |
| 65 | for (const fixture of fixtures) { |
| 66 | await page.locator(".project-tree__topic-main").filter({ has: page.getByText(fixture.kind, { exact: true }) }).click(); |
| 67 | let selected; |
| 68 | await waitForSmokeCondition(async () => { |
| 69 | selected = (await invoke("ListTabs")).find(tab => tab.active && tab.sessionPath === fixture.path); |
| 70 | return Boolean(selected); |
| 71 | }); |
| 72 | const ticket = { tabId: selected.id }; |
| 73 | await page.waitForFunction(kind => document.querySelector(".chat-transcript")?.textContent?.includes(`NATIVE_${kind}_QUESTION_139`), fixture.kind); |
| 74 | const visible = await page.locator(".chat-transcript").innerText(); |
| 75 | for (const other of fixtures.filter(other => other !== fixture)) { |
| 76 | assert.equal(visible.includes(`NATIVE_${other.kind}_QUESTION_`), false, "previous session remained visible"); |
| 77 | } |
| 78 | const composer = page.locator("#composer-input"); |
| 79 | await composer.fill("Unsaved draft while the external writer owns execution"); |
| 80 | assert.equal(await page.locator(".composer__btn--send").isEnabled(), false); |
| 81 | await composer.fill(""); |
| 82 | const handle = await invoke("BeginSessionHistoryReadForTab", [ticket.tabId]); |
| 83 | assert.equal(handle.storageBackend, "legacy"); |
| 84 | const newest = await invoke("ReadSessionHistorySlice", [handle.id, { entries: 32, turns: 32, bytes: 1 << 20 }]); |
| 85 | assert.equal(newest.status, "ready"); |
| 86 | assert.equal(newest.page.hasOlder, true); |
| 87 | assert.equal(newest.page.entries.length <= 32, true); |
| 88 | const outline = await invoke("ReadSessionHistoryOutline", [handle.id, { limit: 128 }]); |
| 89 | assert.equal(outline.status, "ready"); |
| 90 | assert.equal(outline.entries.length, 128); |
| 91 | assert.equal(outline.totalTurns, 140); |
| 92 | const target = outline.entries[3]; |
| 93 | const location = await invoke("LocateSessionHistoryMessage", [handle.id, target.messageId, outline.snapshotSequence]); |
| 94 | assert.equal(location.status, "ready"); |
| 95 | const located = await invoke("ReadSessionHistorySlice", [handle.id, { anchor: "turn", turn: 4, entries: 2, generation: outline.generation, snapshotSequence: outline.snapshotSequence }]); |
| 96 | assert.equal(located.status, "ready"); |
| 97 | assert.equal(located.page.entries.at(-1).message.content, `NATIVE_${fixture.kind}_QUESTION_3`); |
| 98 | assert.ok(handle.capabilities.includes("history-native-search-v1")); |
| 99 | let search; |
| 100 | await waitForSmokeCondition(async () => { |
| 101 | search = await invoke("SearchSessionHistoryRead", [handle.id, `NATIVE_${fixture.kind}_QUESTION_`, "", 2]); |
| 102 | assert.ok(["preparing", "ready"].includes(search.status)); |
| 103 | if (search.status === "preparing") assert.equal(search.hits.length, 0); |
| 104 | return search.status === "ready"; |
| 105 | }); |
| 106 | assert.equal(search.hits.length, 2); |
| 107 | assert.equal(search.hasMore, true); |
| 108 | assert.equal(search.hits[0].preview, `NATIVE_${fixture.kind}_QUESTION_139`); |
| 109 | const searchLocation = await invoke("LocateSessionHistoryMessage", [handle.id, search.hits[0].messageId, search.snapshotSequence]); |
| 110 | assert.equal(searchLocation.status, "ready"); |
| 111 | const nextSearch = await invoke("SearchSessionHistoryRead", [handle.id, `NATIVE_${fixture.kind}_QUESTION_`, search.nextCursor, 2]); |
| 112 | assert.equal(nextSearch.status, "ready"); |
| 113 | assert.equal(nextSearch.hits[0].preview, `NATIVE_${fixture.kind}_QUESTION_137`); |
| 114 | const ref = newest.page.entries.flatMap(entry => entry.refs ?? []).find(ref => ref.field === "content"); |
| 115 | assert.equal(ref?.readHandleId, handle.id); |
| 116 | let text = ""; |
| 117 | for (let index = 0; ; index++) { |
| 118 | const chunk = await invoke("HistoryContentForTab", [ticket.tabId, ref, index]); |
| 119 | assert.equal(chunk.stale, false); |
| 120 | text += chunk.data; |
| 121 | if (chunk.done) break; |
| 122 | } |
| 123 | assert.equal(text, answer); |
| 124 | await invoke("ReleaseSessionHistoryRead", [handle.id]); |
| 125 | assert.equal((await invoke("HistoryContentForTab", [ticket.tabId, ref, 0])).stale, true); |
| 126 | const reopen = await invoke("BeginSessionHistoryReadForTab", [ticket.tabId]); |
| 127 | assert.equal((await invoke("ReadSessionHistorySlice", [reopen.id, { entries: 2 }])).status, "ready"); |
| 128 | assert.equal((await invoke("HistoryContentForTab", [ticket.tabId, ref, 0])).stale, true); |
| 129 | await invoke("ReleaseSessionHistoryRead", [reopen.id]); |
| 130 | const compatibility = await invoke("HistorySliceForTab", [ticket.tabId, { entries: 2 }]); |
| 131 | const compatibilityRef = compatibility.entries.flatMap(entry => entry.refs ?? []).find(ref => ref.field === "content"); |
| 132 | assert.ok(compatibilityRef, JSON.stringify({ source: compatibility.source, error: compatibility.error, |
| 133 | stale: compatibility.stale, entries: compatibility.entries.map(entry => ({ id: entry.id, refs: entry.refs?.length })) })); |
| 134 | assert.equal(compatibilityRef.readHandleId ?? "", ""); |
| 135 | const compatibilityChunk = await invoke("HistoryContentForTab", [ticket.tabId, compatibilityRef, 0]); |
| 136 | assert.equal(compatibilityChunk.stale, false); |
| 137 | assert.ok(compatibilityChunk.data && answer.startsWith(compatibilityChunk.data)); |
| 138 | await waitForSmokeCondition(async () => (await invoke("ListTabs")).find(tab => tab.id === ticket.tabId)?.runtime?.phase === "lease_blocked"); |
| 139 | assert.equal(readFileSync(fixture.path, "utf8"), fixture.body); |
| 140 | if (fixture.kind === "events") assert.equal(readFileSync(fixture.eventPath, "utf8"), fixture.events); |
| 141 | assert.equal(existsSync(fixture.path.replace(/\.jsonl$/, ".display-index.json")), false); |
| 142 | console.log(`PASS ${fixture.kind}: bounded pages, outline, direct anchor, cold search and hit location, complete Unicode content, released refs fenced, unbound content, writer conflict; source unchanged`); |
| 143 | } |
| 144 | await waitForSmokeCondition(async () => { |
| 145 | const topics = await invoke("ListProjectTopics", [{ scope: "global", limit: 50 }]); |
| 146 | assert.equal(topics.failedDirectories, 0, "optional absent history roots reported as failed"); |
| 147 | return topics.complete && topics.pendingDirectories === 0; |
| 148 | }); |
| 149 | const workspace = { scope: "global" }; |
| 150 | let organization = await invoke("GetSessionOrganization", [workspace]); |
| 151 | const mutate = async mutation => { |
| 152 | // Match the production client's CAS contract: first-time legacy preference |
| 153 | // import may advance the revision before the requested mutation is applied. |
| 154 | for (let attempt = 0; attempt < 3; attempt++) { |
| 155 | organization = await invoke("UpdateSessionOrganization", [workspace, organization.revision, mutation]); |
| 156 | if (organization.applied) return; |
| 157 | } |
| 158 | assert.fail("organization mutation could not settle under the production CAS contract"); |
| 159 | }; |
| 160 | await mutate({ kind: "create-group", groupId: "native-fixtures", title: "Native fixtures" }); |
| 161 | const sources = (await invoke("ListProjectTopics", [{ scope: "global", limit: 50 }])).items |
| 162 | .filter(node => fixtures.some(fixture => fixture.path === node.sessionPath)); |
| 163 | assert.equal(sources.length, 2); |
| 164 | for (const node of sources) await mutate({ kind: "set-group", groupId: "native-fixtures", target: { source: node.source } }); |
| 165 | const filtered = await invoke("ListProjectTopics", [{ scope: "global", groupFilter: "group", groupId: "native-fixtures", query: "CHECKPOINT", limit: 1 }]); |
| 166 | assert.equal(filtered.items.length, 1); |
| 167 | assert.equal(filtered.items[0].sessionPath, fixtures.find(fixture => fixture.kind === "checkpoint").path); |
| 168 | assert.equal(filtered.nextCursor ?? "", ""); |
| 169 | await invoke("ReleaseReadSnapshot", [filtered.snapshotId]); |
| 170 | const groupedRequest = { scope: "global", groupFilter: "group", groupId: "native-fixtures", limit: 1 }; |
| 171 | const first = await invoke("ListProjectTopics", [groupedRequest]); |
| 172 | assert.equal(first.items.length, 1); |
| 173 | assert.ok(first.nextCursor); |
| 174 | const removed = sources.find(node => node.sessionPath !== first.items[0].sessionPath); |
| 175 | await mutate({ kind: "set-group", groupId: "", target: { source: removed.source } }); |
| 176 | const retained = await invoke("ListProjectTopics", [{ ...groupedRequest, cursor: first.nextCursor }]); |
| 177 | assert.equal(retained.items.length, 1); |
| 178 | assert.equal(retained.items[0].sessionPath, removed.sessionPath); |
| 179 | assert.equal(retained.nextCursor ?? "", ""); |
| 180 | await invoke("ReleaseReadSnapshot", [first.snapshotId]); |
| 181 | const fresh = await invoke("ListProjectTopics", [groupedRequest]); |
| 182 | assert.equal(fresh.items.length, 1); |
| 183 | assert.equal(fresh.items[0].sessionPath, first.items[0].sessionPath); |
| 184 | assert.equal(fresh.nextCursor ?? "", ""); |
| 185 | await invoke("ReleaseReadSnapshot", [fresh.snapshotId]); |
| 186 | console.log("PASS native group paging: source membership, text filtering, retained cursor, refreshed membership"); |
| 187 | const ready = parseServiceReady(readFileSync(join(home, "desktop-shell/logs/shell.log"), "utf8")); |
| 188 | assert.ok(ready); |
| 189 | await closeAndVerify(application, { shellPid: await application.evaluate(() => process.pid), servicePid: ready.pid }); |
| 190 | application = undefined; |
| 191 | console.log(`PASS production history ${build.version} ${build.commit ?? ""}: normal shell/service exit`); |
| 192 | passed = true; |
| 193 | } catch (error) { |
| 194 | if (page) { |
| 195 | await page.screenshot({ path: join(home, "failure.png"), fullPage: true }).catch(() => {}); |
| 196 | const state = await page.evaluate(async () => ({ body: document.body.innerText, |
| 197 | tabs: await window.reasonixDesktop.invoke("ListTabs", []), |
| 198 | tree: await window.reasonixDesktop.invoke("GetProjectTreeSnapshot", []), |
| 199 | topics: await window.reasonixDesktop.invoke("ListProjectTopics", [{ scope: "global", limit: 50 }]), |
| 200 | })).catch(error => ({ error: String(error) })); |
| 201 | writeFileSync(join(home, "failure.json"), JSON.stringify(state, null, 2)); |
| 202 | } |
| 203 | throw error; |
| 204 | } finally { |
| 205 | await application?.close(); |
| 206 | if (lease && lease.exitCode === null) { const exited = once(lease, "exit"); lease.stdin.end(); await exited; } |
| 207 | if (passed) rmSync(home, { recursive: true, force: true }); |
| 208 | else console.error(`Native history evidence retained at ${home}`); |
| 209 | } |
| 210 |