From 856167ff91f9ac65e4e61d059540e7ff947914f4 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:15:42 +0800 Subject: [PATCH 1/5] docs: record second native automation attempt --- docs/releases/1.0.0/readiness.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.0.0/readiness.md b/docs/releases/1.0.0/readiness.md index c0a8dfd..626a1e4 100644 --- a/docs/releases/1.0.0/readiness.md +++ b/docs/releases/1.0.0/readiness.md @@ -10,7 +10,7 @@ Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (arm64); Obsidian 1.13.7 install | Output integrity | PASS | merged main 207a4d3 (PR #84, commit b44207c) | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. Five export suites 145/145, full suite green, lint and build exit 0. | T6 reruns the two-run case through the native export dialog | | Outcomes | PASS | e2c9dd3 (T3), 23e670a (T4) | `resolveExportStatus` state table (8 rows) plus 9 integration scenarios: early cancel, mid-batch cancel, cancel after render, second-source read failure (partial), PDF renderer rejection (failed), shared-attachment retry, missing input keeps original total, report-write failure stays completed-with-warning, unresolved-link-only stays completed. Runner finalizes through one path; `onFileComplete` reports completed count minus one. `exportResultMessage` distinguishes completed/partial/cancelled/failed for the UI. Full suite 411/411, lint and build exit 0. | Native dialog feedback wording checked in T6 (A08) | | Headless artifacts | PASS | 3098745 | Fixture generator: 520 synthetic files, all manifest SHA-256 verified; second run refused non-empty destination; PNGs decode via macOS `sips` with matching dimensions (640x240, 160x100). Contract suite 10/10: Markdown/HTML-fallback/DOCX/EPUB content cases (markers, tables, code, XML validity, relationships, spine resolution, image byte equality, no `app://`), folder batch (nested primaries, relative links, shared attachment bytes), collision A→B, export-report name protection, missing-attachment and cancellation injections. `RELEASE_ARTIFACT_DIR` persistence verified with SHA-256 index (no PDF claims). Independent `unzip -t` passed on generated DOCX and EPUB. | Native artifact acceptance in T6 | -| Native artifacts | BLOCKED | candidate build main.js SHA-256 c0f73a64f49b59a37a4fc83668d45cc084fe0c1b1ec6a94168249a55e275bfdc (from main f0552fa) | 2026-09-17 session: tools installed and verified — OpenJDK 26 + EPUBCheck 5.2.1, LibreOffice 26.8.0.3 (~/Applications), Books/Preview present. Candidate installed into `/Users/Roger/my-vault`'s plugin directory and confirmed loaded in Obsidian 1.13.7 on macOS 27 (ribbon entry visible); 521 fixture files staged, then fully removed and the original plugin restored (main.js bc226ff2…) after the session. BLOCKED reason: the export dialog could not be driven by UI automation — one initial ribbon AXPress opened the modal, but select changes, Next/Export button presses, synthetic keys and coordinate clicks never reached the Electron DOM layer, and DOM context menus do not respond. A01-A12 were NOT executed; no native PASS is claimed. Supplementary real-tool checks of HEADLESS-generated artifacts (clearly not native evidence): EPUBCheck 5.2.1 `--failonwarnings` → 0 fatals/0 errors/0 warnings; LibreOffice headless conversion of the DOCX → clean PDF; `unzip -t` clean. | Human-driven or working-automation native session per protocol T6.2/T6.3 against the fixed candidate (tools are now installed); QA workspace retained at the recorded temporary path | +| Native artifacts | BLOCKED | candidate build main.js SHA-256 c0f73a64f49b59a37a4fc83668d45cc084fe0c1b1ec6a94168249a55e275bfdc (from main f0552fa) | 2026-09-17 session: tools installed and verified — OpenJDK 26 + EPUBCheck 5.2.1, LibreOffice 26.8.0.3 (~/Applications), Books/Preview present. Candidate installed into `/Users/Roger/my-vault`'s plugin directory and confirmed loaded in Obsidian 1.13.7 on macOS 27 (ribbon entry visible); 521 fixture files staged, then fully removed and the original plugin restored (main.js bc226ff2…) after the session. BLOCKED reason: the export dialog could not be driven by UI automation — one initial ribbon AXPress opened the modal, but select changes, Next/Export button presses, synthetic keys and coordinate clicks never reached the Electron DOM layer, and DOM context menus do not respond. A01-A12 were NOT executed; no native PASS is claimed. Supplementary real-tool checks of HEADLESS-generated artifacts (clearly not native evidence): EPUBCheck 5.2.1 `--failonwarnings` → 0 fatals/0 errors/0 warnings; LibreOffice headless conversion of the DOCX → clean PDF; `unzip -t` clean. | Second automation attempt on 2026-09-17 with a focused key window: candidate rebuilt (main.js 530550d9…) and reinstalled, fixtures restaged, Obsidian restarted. Findings — the ribbon AXPress opens the modal only as the first press after an app restart; the export-folder textfield accepts AX value setting; but the Next button and format select never activate: two vision-derived coordinate clicks (766,561 then 707,570) both landed on the modal backdrop and dismissed it, and no export ran (vault output directory never created). Vault fully restored again (original main.js bc226ff2…, fixtures removed). Conclusion unchanged: A01-A12 require a human-driven session or a fundamentally different automation path (e.g. an Obsidian-driven test harness); synthetic macOS input cannot drive this Electron modal reliably. | | Platforms | BLOCKED | Unmeasured | Only the macOS row of the T6.4 matrix is executable here, and it is blocked by the same dialog-automation gap above. Missing — Windows, Linux, iOS, Android environments and an isolated desktop Obsidian 1.4.0 install. None tested; no platform claim made. | Execute the bounded platform matrix on the required devices after the macOS interactive gap is closed | | Documentation | IN PROGRESS | — | T7 executing 2026-09-17 with evidence-scoped content: descriptions/setting text are code-backed; the capability table cites headless-verified rows and marks native verification pending; the privacy paragraph is unchanged (A10 not run, per plan gating). | Capability table native columns and privacy wording finalize after T6 evidence | | Release gate | PASS (local) | 064f518 | `scripts/check-version.mjs` extended to lockfile root and `versions.json`↔`minAppVersion` mapping; `node --test scripts/check-version.test.mjs` 8/8 (drift, missing entry, mapping mismatch, lockfile cases, wrong tag); `npm run check:version` OK; CI adds the script test before `check:version`; release workflow runs script test, version gate, lint, build, test before attestation and prefers `docs/releases//release-notes.md`; both workflow files parse as valid YAML; CLAUDE.md release guidance updated. | None — CI verified the workflow changes on PR #85 (verify pass) | From 1d40a3710dcb29a819d2592c726dc6b7fca75292 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:45:12 +0800 Subject: [PATCH 2/5] chore: add CDP native acceptance harness Drives the real Obsidian export dialog over the DevTools protocol with trusted renderer input. Documented accommodations: switching the test vault from macOS default native menus to DOM menus, and launching with anti-throttling flags so native-render batches match foreground-user conditions. Includes the A01-A12 case runners, A03/A04 disk-state re-verification, and the T6.3 independent-tool inspection script. --- scripts/native-acceptance/README.md | 62 ++++ scripts/native-acceptance/cdp.mjs | 132 ++++++++ scripts/native-acceptance/driver.mjs | 285 +++++++++++++++++ scripts/native-acceptance/inspect-t63.mjs | 178 +++++++++++ scripts/native-acceptance/pdf-inspect.swift | 36 +++ scripts/native-acceptance/run-a01-a06.mjs | 221 +++++++++++++ scripts/native-acceptance/run-a07-a12.mjs | 308 +++++++++++++++++++ scripts/native-acceptance/run-a08-md.mjs | 71 +++++ scripts/native-acceptance/run-a12-retry.mjs | 54 ++++ scripts/native-acceptance/run-export.mjs | 85 +++++ scripts/native-acceptance/verify-a03-a04.mjs | 58 ++++ 11 files changed, 1490 insertions(+) create mode 100644 scripts/native-acceptance/README.md create mode 100644 scripts/native-acceptance/cdp.mjs create mode 100644 scripts/native-acceptance/driver.mjs create mode 100644 scripts/native-acceptance/inspect-t63.mjs create mode 100644 scripts/native-acceptance/pdf-inspect.swift create mode 100644 scripts/native-acceptance/run-a01-a06.mjs create mode 100644 scripts/native-acceptance/run-a07-a12.mjs create mode 100644 scripts/native-acceptance/run-a08-md.mjs create mode 100644 scripts/native-acceptance/run-a12-retry.mjs create mode 100644 scripts/native-acceptance/run-export.mjs create mode 100644 scripts/native-acceptance/verify-a03-a04.mjs diff --git a/scripts/native-acceptance/README.md b/scripts/native-acceptance/README.md new file mode 100644 index 0000000..a8495ae --- /dev/null +++ b/scripts/native-acceptance/README.md @@ -0,0 +1,62 @@ +# Native acceptance harness (T6) + +Drives the **real Obsidian app** through its export dialog to execute the +A01–A12 native acceptance cases from the 1.0.0 acceptance protocol, plus the +T6.3 artifact inspections with independent tools. + +## How it works + +Obsidian (Electron) is launched with `--remote-debugging-port`; a minimal CDP +client (`cdp.mjs`) talks to the renderer of the target vault window. All +interactions use **trusted renderer input** (`Input.dispatchMouseEvent`) and +real DOM events — the same code paths a user's clicks invoke. No mocks, no +spies; every export runs the actual plugin pipeline inside Obsidian. + +## Two required environment accommodations + +1. **DOM context menus.** macOS Obsidian defaults to *native* (Electron) menus + when the `nativeMenus` vault config is unset (`updateUseNativeMenu`: + `isMacOS && null => true`). Native menus are invisible to CDP, which is why + earlier OS-level automation attempts failed. `driver.useDomMenus()` sets + `nativeMenus: false` for the session; restore the prior value afterwards + (`restoreMenusConfig`). +2. **Background timer throttling.** Chromium throttles timers in background + windows to ~1/min. Native-renderer batches (HTML/PDF) crawl or appear hung + when the Obsidian window is in the background — a real user exporting with + the window visible does not hit this. Launch Obsidian with: + + ``` + --remote-debugging-port=9223 \ + --disable-background-timer-throttling \ + --disable-backgrounding-occluded-windows \ + --disable-renderer-backgrounding \ + --disable-features=,IntensiveWakeUpThrottling + ``` + +## Usage + +```bash +# 1. Stage fixtures into the vault (scripts/create-release-fixtures.mjs) +# 2. Install the candidate plugin build per T6.1 +# 3. Relaunch Obsidian with the flags above +# 4. Run cases (env vars override the default run workspace): +NATIVE_RUN_DIR=... NATIVE_VAULT_DIR=... NATIVE_STAGING_DIR=... \ + node scripts/native-acceptance/run-a01-a06.mjs + node scripts/native-acceptance/run-a07-a12.mjs # A08/A12 reruns: run-a08-md.mjs, run-a12-retry.mjs + node scripts/native-acceptance/verify-a03-a04.mjs +# 5. Artifact inspections (EPUBCheck, LibreOffice, unzip, Chrome headless, PDFKit) + node scripts/native-acceptance/inspect-t63.mjs # swift pdf-inspect.swift used for PDFs +``` + +Case evidence is written as JSON under `$RUN/artifacts/`, screenshots under +`$RUN/screenshots/`, EPUBCheck reports under `$RUN/logs/`. + +## Files + +- `cdp.mjs` — minimal CDP client (targets, eval, trusted mouse input, screenshots) +- `driver.mjs` — Obsidian-specific driving (menus, export modal, notices, settings) +- `run-export.mjs` — shared case-runner utilities +- `run-a01-a06.mjs` / `run-a07-a12.mjs` — case execution +- `run-a08-md.mjs`, `run-a12-retry.mjs` — focused reruns +- `verify-a03-a04.mjs` — disk-state re-verification for A03/A04 +- `inspect-t63.mjs`, `pdf-inspect.swift` — T6.3 independent tool checks diff --git a/scripts/native-acceptance/cdp.mjs b/scripts/native-acceptance/cdp.mjs new file mode 100644 index 0000000..a596a7b --- /dev/null +++ b/scripts/native-acceptance/cdp.mjs @@ -0,0 +1,132 @@ +// Minimal Chrome DevTools Protocol client for driving the real Obsidian +// renderer (Electron) — no synthetic macOS input. Node >= 22 (global WebSocket). +import fs from "node:fs"; + +export const PORT = 9223; + +export async function listTargets() { + const res = await fetch(`http://127.0.0.1:${PORT}/json`); + if (!res.ok) throw new Error(`CDP /json failed: ${res.status}`); + return res.json(); +} + +export async function findVaultTarget(vaultName) { + const targets = await listTargets(); + const pages = targets.filter((t) => t.type === "page"); + const hit = pages.find((t) => t.title.includes(`${vaultName} - Obsidian`)); + if (!hit) { + throw new Error( + `No CDP page target for vault "${vaultName}". Pages: ${pages.map((t) => t.title).join(" ; ")}`, + ); + } + return hit; +} + +export class CDP { + constructor(ws, title) { + this.ws = ws; + this.title = title; + this.nextId = 1; + this.pending = new Map(); + ws.addEventListener("message", (ev) => { + const msg = JSON.parse(ev.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} (${msg.error.data ?? ""})`)); + else resolve(msg.result); + } + }); + } + + static async connect(wsUrl, title = "") { + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("CDP websocket connect timeout")), 10_000); + ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); + ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error("CDP websocket error")); }, { once: true }); + }); + return new CDP(ws, title); + } + + send(method, params = {}) { + const id = this.nextId++; + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + setTimeout(() => { + if (this.pending.has(id)) { + this.pending.delete(id); + reject(new Error(`CDP call timeout: ${method}`)); + } + }, 60_000); + }); + this.ws.send(JSON.stringify({ id, method, params })); + return promise; + } + + close() { + this.ws.close(); + } + + /** Evaluate an expression (or async IIFE) returning a JSON value. Throws on JS exceptions. */ + async evalJs(expression) { + const r = await this.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (r.exceptionDetails) { + const d = r.exceptionDetails; + throw new Error(`evalJs exception: ${d.exception?.description ?? d.text}`); + } + return r.result?.value; + } + + /** Poll until expression returns a truthy value; returns that value. */ + async waitFor(expression, { timeoutMs = 30_000, pollMs = 150, label = expression.slice(0, 80) } = {}) { + const deadline = Date.now() + timeoutMs; + let lastErr = null; + while (Date.now() < deadline) { + try { + const v = await this.evalJs(expression); + if (v) return v; + } catch (err) { + lastErr = err; + } + await new Promise((r) => setTimeout(r, pollMs)); + } + throw new Error(`waitFor timeout (${timeoutMs}ms): ${label}${lastErr ? ` — last error: ${lastErr.message}` : ""}`); + } + + /** Click the center of the element resolved by `elExpr` using trusted renderer input. */ + async clickEl(elExpr, { label = "element", timeoutMs = 10_000 } = {}) { + await this.waitFor(`!!(${elExpr})`, { timeoutMs, label: `presence: ${label}` }); + const rect = await this.evalJs(`(() => { const el = ${elExpr}; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); + if (!rect || rect.w === 0 || rect.h === 0) throw new Error(`clickEl: ${label} has empty rect`); + const x = rect.x + rect.w / 2; + const y = rect.y + rect.h / 2; + await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none" }); + await this.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 }); + await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 }); + return { x, y }; + } + + /** Right-click the center of the element resolved by `elExpr` using trusted renderer input. */ + async rightClickEl(elExpr, { label = "element", timeoutMs = 10_000 } = {}) { + await this.waitFor(`!!(${elExpr})`, { timeoutMs, label: `presence: ${label}` }); + const rect = await this.evalJs(`(() => { const el = ${elExpr}; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); + if (!rect || rect.w === 0 || rect.h === 0) throw new Error(`rightClickEl: ${label} has empty rect`); + const x = rect.x + rect.w / 2; + const y = rect.y + rect.h / 2; + await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none" }); + await this.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "right", clickCount: 1 }); + await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "right", clickCount: 1 }); + return { x, y }; + } + + async screenshot(filePath) { + const r = await this.send("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(filePath, Buffer.from(r.data, "base64")); + return filePath; + } +} diff --git a/scripts/native-acceptance/driver.mjs b/scripts/native-acceptance/driver.mjs new file mode 100644 index 0000000..d39f797 --- /dev/null +++ b/scripts/native-acceptance/driver.mjs @@ -0,0 +1,285 @@ +// Obsidian-specific driving helpers on top of the raw CDP client. +// All interactions go through the real DOM/trusted renderer input of the +// running Obsidian app — the same code paths a user's clicks invoke. +import { CDP } from "./cdp.mjs"; + +// The last .modal-container in DOM order is the topmost open modal. +const MODAL = `document.querySelectorAll('.modal-container').length ? document.querySelectorAll('.modal-container')[document.querySelectorAll('.modal-container').length-1] : null`; +const NOTICE_RE = /^Export (complete|cancelled|failed|partially)/; + +export class ObsidianDriver { + constructor(cdp) { + this.cdp = cdp; + } + + static async connect(vaultName = "my-vault") { + const { findVaultTarget } = await import("./cdp.mjs"); + const target = await findVaultTarget(vaultName); + const cdp = await CDP.connect(target.webSocketDebuggerUrl, target.title); + return new ObsidianDriver(cdp); + } + + async info() { + return this.cdp.evalJs(`(() => ({ + vault: app.vault.getName(), + obsidianVersion: app.appId ? 'obsidian' : 'obsidian', + pluginVersion: app.plugins?.plugins?.['document-exporter']?.manifest?.version ?? null, + pluginEnabled: !!app.plugins?.plugins?.['document-exporter'], + command: !!app.commands?.commands?.['document-exporter:export-documents'], + ua: navigator.userAgent, + }))()`); + } + + async setSettings(partial) { + return this.cdp.evalJs(`(async () => { + const p = app.plugins.plugins['document-exporter']; + Object.assign(p.settings, ${JSON.stringify(partial)}); + await p.saveSettings(); + return { ...p.settings }; + })()`); + } + + async openFileInEditor(vaultPath) { + return this.cdp.evalJs(`(async () => { + const f = app.vault.getAbstractFileByPath(${JSON.stringify(vaultPath)}); + if (!f) throw new Error('file not found: ${vaultPath}'); + await app.workspace.getLeaf(false).openFile(f); + return app.workspace.getActiveFile()?.path ?? null; + })()`); + } + + /** Tag existing notices so we can detect only NEW completion notices. */ + async markNoticesSeen() { + return this.cdp.evalJs(`(() => { + let n = 0; + for (const el of document.querySelectorAll('.notice')) { + if (!el.dataset.harnessSeen) { el.dataset.harnessSeen = '1'; n++; } + } + return n; + })()`); + } + + /** Wait for a NEW completion/failure notice and return its full text. */ + async waitExportNotice({ timeoutMs = 180_000 } = {}) { + const text = await this.cdp.waitFor(`(() => { + for (const el of document.querySelectorAll('.notice')) { + if (el.dataset.harnessSeen) continue; + const t = (el.textContent ?? '').trim(); + if (${NOTICE_RE.toString()}.test(t)) return t; + } + return null; + })()`, { timeoutMs, pollMs: 200, label: "export completion notice" }); + return text; + } + + async currentNotices() { + return this.cdp.evalJs(`[...document.querySelectorAll('.notice')].map(n => (n.textContent ?? '').trim())`); + } + + async progressVisible() { + return this.cdp.evalJs(`!!document.querySelector('.de-progress-notice')`); + } + + async progressText() { + return this.cdp.evalJs(`(() => { + const el = document.querySelector('.de-progress-notice'); + return el ? (el.textContent ?? '').trim() : null; + })()`); + } + + async clickProgressCancel() { + return this.cdp.clickEl(`document.querySelector('.de-progress-cancel')`, { label: "progress Cancel button", timeoutMs: 15_000 }); + } + + // ---- Environment setup / teardown ---- + + // macOS Obsidian defaults to NATIVE (Electron) context menus when the + // `nativeMenus` vault config is unset (app.js updateUseNativeMenu: + // isMacOS && null => true). Native menus are invisible to CDP. Switch the + // test vault to DOM menus so context-menu entries can be driven; the + // original config value is returned for restoration. + async useDomMenus() { + const before = await this.cdp.evalJs(`app.vault.getConfig('nativeMenus')`); + await this.cdp.evalJs(`(async () => { + await app.vault.setConfig('nativeMenus', false); + app.updateUseNativeMenu(); + })()`); + return before; + } + + async restoreMenusConfig(priorValue) { + return this.cdp.evalJs(`(async () => { + await app.vault.setConfig('nativeMenus', ${JSON.stringify(priorValue)}); + app.updateUseNativeMenu(); + return app.vault.getConfig('nativeMenus'); + })()`); + } + + async ensureFileExplorerVisible() { + await this.cdp.evalJs(`(async () => { + app.workspace.leftSplit.expand(); + const headers = [...document.querySelectorAll('.workspace-sidedock-vault-tab, .workspace-tab-header, .sidebar-tab-header')]; + const hit = headers.find((h) => ((h.getAttribute('aria-label') || h.textContent || '').toLowerCase().includes('files')) || h.dataset?.type === 'file-explorer'); + hit?.click(); + })()`); + } + + async pressEscape() { + for (const type of ["keyDown", "keyUp"]) { + await this.cdp.send("Input.dispatchKeyEvent", { + type, key: "Escape", code: "Escape", + windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27, + }); + } + } + + async dismissMenu() { + await this.pressEscape(); + await this.cdp.waitFor(`!document.querySelector('.menu')`, { timeoutMs: 3_000, label: "menu dismissed" }).catch(() => {}); + } + + findMenuItem(title) { + return `[...document.querySelectorAll('.menu .menu-item')].find(el => (el.textContent ?? '').includes(${JSON.stringify(title)}))`; + } + + async clickMenuItem(title) { + await this.cdp.waitFor(`!!${this.findMenuItem(title)}`, { timeoutMs: 5_000, label: `menu item ${title}` }); + await this.cdp.clickEl(this.findMenuItem(title), { label: `menu item ${title}` }); + } + + // ---- Entry points ---- + + async openExportViaCommand() { + await this.cdp.evalJs(`app.commands.executeCommandById('document-exporter:export-documents')`); + await this.waitForModalForm(); + } + + /** Real editor right-click -> context menu -> "Export current file". */ + async openExportViaEditorContextMenu() { + const target = `document.querySelector('.workspace-leaf.mod-active .view-content')`; + await this.cdp.waitFor(`!!(${target})`, { timeoutMs: 10_000, label: "active editor view" }); + await this.cdp.rightClickEl(target, { label: "active editor content", timeoutMs: 15_000 }); + await this.clickMenuItem("Export current file"); + await this.waitForModalForm(); + } + + /** Real file-explorer folder right-click -> "Export this folder". */ + async openExportViaFolderContextMenu(folderPath) { + await this.ensureFileExplorerVisible(); + const nav = `document.querySelector('.nav-folder-title[data-path="${folderPath}"]')`; + await this.cdp.waitFor(`!!(${nav})`, { timeoutMs: 15_000, label: `file-explorer node ${folderPath}` }); + await this.cdp.evalJs(`${nav}.scrollIntoView({ block: 'center' })`); + await new Promise((r) => setTimeout(r, 250)); + await this.cdp.rightClickEl(nav, { label: `folder nav ${folderPath}` }); + await this.clickMenuItem("Export this folder"); + await this.waitForModalForm(); + } + + // ---- Modal form driving ---- + + async waitForModalForm({ timeoutMs = 10_000 } = {}) { + await this.cdp.waitFor(`(() => { const m = ${MODAL}; return !!m && (m.querySelector('h2')?.textContent ?? '') === 'Export documents'; })()`, + { timeoutMs, label: "export modal form" }); + } + + async waitForConfirmStep({ timeoutMs = 10_000 } = {}) { + await this.cdp.waitFor(`(() => { const m = ${MODAL}; return !!m && (m.querySelector('h2')?.textContent ?? '') === 'Confirm export'; })()`, + { timeoutMs, label: "confirm step" }); + } + + async setSelectByLabel(selectIndex, value) { + return this.cdp.evalJs(`(() => { + const m = ${MODAL}; + const s = m.querySelectorAll('select')[${selectIndex}]; + if (!s) throw new Error('select ${selectIndex} not found'); + s.value = ${JSON.stringify(value)}; + s.dispatchEvent(new Event('change', { bubbles: true })); + return s.value; + })()`); + } + + async setInputByLabel(labelText, value) { + return this.cdp.evalJs(`(() => { + const m = ${MODAL}; + const rows = [...m.querySelectorAll('.export-modal-row')]; + const row = rows.find(r => (r.querySelector('label')?.textContent ?? '') === ${JSON.stringify(labelText)}); + if (!row) throw new Error('row with label ${labelText} not found'); + const input = row.querySelector('input'); + if (!input) throw new Error('input in row ${labelText} not found'); + input.value = ${JSON.stringify(value)}; + input.dispatchEvent(new Event('input', { bubbles: true })); + return input.value; + })()`); + } + + async outputFolderInput() { + return this.cdp.evalJs(`(() => { const m = ${MODAL}; return [...m.querySelectorAll('.export-modal-input-group input')][0]?.value ?? null; })()`); + } + + /** In "Selected files" source mode: open the picker, check the given paths, Done. */ + async pickFiles(paths) { + await this.cdp.clickEl(`(() => { const m = ${MODAL}; return [...m.querySelectorAll('button')].find(b => /Choose files|file\\(s\\) selected/.test(b.textContent)); })()`, + { label: "Choose files button" }); + await this.cdp.waitFor(`!!document.querySelector('.file-picker-modal')`, { timeoutMs: 5_000, label: "file picker modal" }); + const picked = await this.cdp.evalJs(`(() => { + const wanted = ${JSON.stringify(paths)}; + const items = [...document.querySelectorAll('.file-picker-item')]; + const checked = []; + for (const item of items) { + const path = (item.textContent ?? '').trim(); + const box = item.querySelector('input[type=checkbox]'); + const should = wanted.includes(path); + if (box.checked !== should) box.click(); + if (should) checked.push(path); + } + return { checked, missing: wanted.filter(w => !checked.includes(w)) }; + })()`); + if (picked.missing.length) throw new Error(`file picker: not found in list: ${picked.missing.join(", ")}`); + await this.cdp.clickEl(`document.querySelector('.file-picker-done-btn')`, { label: "file picker Done" }); + await this.cdp.waitFor(`!document.querySelector('.file-picker-modal')`, { timeoutMs: 5_000, label: "file picker closed" }); + return picked.checked; + } + + /** + * Drive the modal form: choose source/profile/paths, fill output folder+name, + * click Next (trusted click), verify the confirm summary. + */ + async fillFormAndNext({ source = "current-file", folderPath = null, filePaths = null, profile, outputFolder, outputName = null }) { + await this.waitForModalForm(); + await this.setSelectByLabel(0, source); + if (source === "folder") { + await this.setInputByLabel("Folder path", folderPath); + } else if (source === "files") { + await this.pickFiles(filePaths); + } + await this.setSelectByLabel(1, profile); + await this.setInputByLabel("Output folder", outputFolder); + if (outputName != null) { + await this.setInputByLabel(source === "current-file" ? "File name" : "Folder name", outputName); + } + await this.cdp.clickEl(`(() => { const m = ${MODAL}; return [...m.querySelectorAll('button')].find(b => b.textContent.trim() === 'Next'); })()`, + { label: "Next button" }); + await this.waitForConfirmStep(); + return this.confirmSummary(); + } + + async confirmSummary() { + return this.cdp.evalJs(`(() => { const m = ${MODAL}; return [...m.querySelectorAll('.export-confirm-summary p')].map(p => p.textContent); })()`); + } + + async confirmExport() { + await this.cdp.clickEl(`(() => { const m = ${MODAL}; return [...m.querySelectorAll('button')].find(b => b.textContent.trim() === 'Export'); })()`, + { label: "Export button" }); + await this.cdp.waitFor(`!(${MODAL})`, { timeoutMs: 5_000, label: "modal closed after confirm" }); + } + + async cancelModal() { + await this.cdp.clickEl(`(() => { const m = ${MODAL}; return [...m.querySelectorAll('button')].find(b => b.textContent.trim() === 'Cancel'); })()`, + { label: "Cancel button" }); + await this.cdp.waitFor(`!(${MODAL})`, { timeoutMs: 5_000, label: "modal closed after cancel" }); + } + + async screenshot(path) { + return this.cdp.screenshot(path); + } +} diff --git a/scripts/native-acceptance/inspect-t63.mjs b/scripts/native-acceptance/inspect-t63.mjs new file mode 100644 index 0000000..5a60b9a --- /dev/null +++ b/scripts/native-acceptance/inspect-t63.mjs @@ -0,0 +1,178 @@ +// T6.3 — inspect actual documents with independent real tools. +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const RUN = process.env.NATIVE_RUN_DIR ?? "/var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD/native/run3-20260918"; +const QA = process.env.NATIVE_QA_DIR ?? "/var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD"; +const VAULT = process.env.NATIVE_VAULT_DIR ?? "/Users/Roger/my-vault"; +const STAGING = "release-acceptance-1.0-run3"; +const OUT = `${STAGING}/out`; +const harness = path.dirname(fileURLToPath(import.meta.url)); +const report = {}; +const sh = (cmd, args, opts = {}) => { + try { + const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 300_000, ...opts }); + return { ok: true, stdout }; + } catch (e) { + return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), status: e.status }; + } +}; + +const pdfInspect = (rel, sentinels = []) => { + const abs = path.join(VAULT, rel); + const r = sh("swift", [`${harness}/pdf-inspect.swift`, abs, ...sentinels]); + const lines = r.stdout.split("\n").filter(Boolean); + return { ok: r.ok, result: Object.fromEntries(lines.map((l) => l.split("=")).map(([k, ...v]) => [k, v.join("=")])) }; +}; + +const epubCheck = (rel) => { + const abs = path.join(VAULT, rel); + const jsonReport = `${RUN}/logs/epubcheck-${rel.replaceAll("/", "_")}.json`; + const r = sh("/opt/homebrew/opt/openjdk/bin/java", [ + "-jar", `${QA}/tools/epubcheck-5.2.1/epubcheck.jar`, + "--failonwarnings", "--json", jsonReport, abs, + ]); + let summary = null; + try { summary = JSON.parse(fs.readFileSync(jsonReport, "utf8")).messages ? null : null; } catch {} + let counts = null; + try { + const j = JSON.parse(fs.readFileSync(jsonReport, "utf8")); + counts = { fatal: j.messages?.filter((m) => m.severity === "FATAL").length ?? null, + error: j.messages?.filter((m) => m.severity === "ERROR").length ?? null, + warn: j.messages?.filter((m) => m.severity === "WARNING").length ?? null }; + } catch {} + return { ok: r.ok, status: r.status, counts, report: jsonReport }; +}; + +const unzipT = (rel) => { + const r = sh("unzip", ["-t", path.join(VAULT, rel)]); + return { ok: r.ok, tail: r.stdout.trim().split("\n").slice(-2).join(" | ") }; +}; + +const loConvert = (rel, outSub) => { + const abs = path.join(VAULT, rel); + const outdir = `${RUN}/artifacts/lo-${outSub}`; + fs.mkdirSync(outdir, { recursive: true }); + const r = sh(`${process.env.HOME}/Applications/LibreOffice.app/Contents/MacOS/soffice`, + ["--headless", "--convert-to", "pdf", "--outdir", outdir, abs]); + const produced = fs.existsSync(`${outdir}/${path.basename(rel).replace(/\.(docx|epub)$/i, "")}.pdf`); + return { ok: r.ok && produced, outdir }; +}; + +const chromeShot = (rel, name, width = 1400) => { + const abs = path.join(VAULT, rel); + const out = `${RUN}/screenshots/t63-${name}.png`; + const r = sh("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", [ + "--headless", "--disable-gpu", `--screenshot=${out}`, `--window-size=${width},2200`, + "--virtual-time-budget=4000", `file://${abs}`, + ]); + return { ok: r.ok && fs.existsSync(out), shot: out }; +}; + +const zipText = (rel, inner) => { + const r = sh("unzip", ["-p", path.join(VAULT, rel), inner]); + return r.ok ? r.stdout : null; +}; + +// ---------- PDF ---------- +report.pdf = {}; +report.pdf["A01-content"] = pdfInspect(`${OUT}/A01/pdf/content.pdf`, ["BEGIN-CONTENT", "END-CONTENT", "中文导出", "CODE-CONTENT", "Alpha", "456"]); +report.pdf["A09-long"] = pdfInspect(`${OUT}/A09/pdf/long.pdf`, ["PAGE-SENTINEL-1", "PAGE-SENTINEL-60", "PAGE-SENTINEL-120", "Section 60", "Left", "Right"]); +report.pdf["A07-tt-host"] = pdfInspect(`${OUT}/A07/pdf/tt/heading-host/heading-host.pdf`, ["WANTED-SENTINEL"]); +report.pdf["A07-ff-host"] = pdfInspect(`${OUT}/A07/pdf/ff/heading-host/heading-host.pdf`, ["WANTED-SENTINEL", "EXCLUDED-SENTINEL"]); +report.pdf["A07-tt-cycle"] = pdfInspect(`${OUT}/A07/pdf/tt/cycle-a/cycle-a.pdf`, ["Cycle A", "Cycle B"]); +report.pdf["A07-ft-adjacency"] = pdfInspect(`${OUT}/A07/pdf/ft/adjacency/adjacency.pdf`, ["AFTER-IMAGE-SENTINEL"]); +report.pdf["A11-limitations"] = pdfInspect(`${OUT}/A11/pdf/limitations.pdf`, ["CALLOUT-SENTINEL"]); + +// ---------- DOCX ---------- +report.docx = {}; +for (const [key, rel] of Object.entries({ + "A01-content": `${OUT}/A01/docx/content.docx`, + "A02-folder-index": `${OUT}/A02/docx/folder/index.docx`, + "A03-selected": `${OUT}/A03/docx/selected/nested/third.docx`, + "A09-long": `${OUT}/A09/docx/long.docx`, + "A11-limitations": `${OUT}/A11/docx/limitations.docx`, +})) { + const xml = zipText(rel, "word/document.xml"); + report.docx[key] = { + unzip: unzipT(rel), + markers: xml ? ["BEGIN-CONTENT", "CODE-CONTENT", "Alpha", "456"].filter((m) => xml.includes(m)) : null, + hyperLink: xml ? xml.includes("https://example.com/") : null, + libreOffice: loConvert(rel, key), + }; +} + +// ---------- EPUB ---------- +report.epub = {}; +for (const [key, rel] of Object.entries({ + "A01-content": `${OUT}/A01/epub/content.epub`, + "A02-folder-index": `${OUT}/A02/epub/folder/index.epub`, + "A11-limitations": `${OUT}/A11/epub/limitations.epub`, +})) { + report.epub[key] = { epubCheck: epubCheck(rel), unzip: unzipT(rel) }; +} + +// ---------- HTML / Markdown (real-browser render + tree checks) ---------- +report.html = {}; +report.html["A01-chrome"] = chromeShot(`${OUT}/A01/html-document/content.html`, "A01-chrome"); +report.html["A02-index-chrome"] = chromeShot(`${OUT}/A02/html-document/folder/index.html`, "A02-index-chrome"); +report.html["A03-chrome"] = chromeShot(`${OUT}/A03/html-document/selected/index.html`, "A03-chrome"); +report.html["A11-chrome"] = chromeShot(`${OUT}/A11/html-document/limitations.html`, "A11-chrome"); + +// link/asset integrity inside exported HTML trees (no references back into the vault) +const htmlIntegrity = (rootRel) => { + const rootAbs = path.join(VAULT, rootRel); + const issues = []; + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".html")) { + const html = fs.readFileSync(p, "utf8"); + if (html.includes("app://")) issues.push(`${path.relative(rootAbs, p)}: app:// reference`); + for (const m of html.matchAll(/(?:src|href)="([^"]+)"/g)) { + const ref = m[1]; + if (/^(https?:|mailto:|#|data:)/.test(ref)) continue; + const target = path.resolve(path.dirname(p), decodeURIComponent(ref.split("#")[0])); + if (!fs.existsSync(target)) issues.push(`${path.relative(rootAbs, p)} -> ${ref} MISSING`); + } + } + } + }; + walk(rootAbs); + return { issues }; +}; +report.htmlIntegrity = { + "A02-folder": htmlIntegrity(`${OUT}/A02/html-document/folder`), + "A03-selected": htmlIntegrity(`${OUT}/A03/html-document/selected`), + "A01": htmlIntegrity(`${OUT}/A01/html-document`), +}; + +// ---------- A11 degradation matrix (from actual outputs) ---------- +{ + const md = fs.readFileSync(path.join(VAULT, `${OUT}/A11/markdown-bundle/limitations.md`), "utf8"); + const html = fs.readFileSync(path.join(VAULT, `${OUT}/A11/html-document/limitations.html`), "utf8"); + const docxXml = zipText(`${OUT}/A11/docx/limitations.docx`, "word/document.xml") ?? ""; + report.a11 = { + markersByFormat: { + markdown: ["CALLOUT-SENTINEL", "Task item", "dataview", "mermaid", "x^2", "absent-block"].map((s) => [s, md.includes(s)]), + html: ["CALLOUT-SENTINEL", "Task item", "dataview", "mermaid", "x^2", "absent-block"].map((s) => [s, html.includes(s)]), + docx: ["CALLOUT-SENTINEL", "Task item", "x^2"].map((s) => [s, docxXml.includes(s)]), + }, + }; +} + +// ---------- tool versions ---------- +const javaVersion = sh("/opt/homebrew/opt/openjdk/bin/java", ["-version"]); +report.tools = { + epubcheck: sh("/opt/homebrew/opt/openjdk/bin/java", ["-jar", `${QA}/tools/epubcheck-5.2.1/epubcheck.jar`, "--version"]).stdout.split("\n")[1] ?? "", + java: (javaVersion.stderr || javaVersion.stdout || "").split("\n")[0], + libreOffice: sh(`${process.env.HOME}/Applications/LibreOffice.app/Contents/MacOS/soffice`, ["--version"]).stdout.trim(), + chrome: sh("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ["--version"]).stdout.trim(), + swift: sh("swift", ["--version"]).stdout.split("\n")[0], +}; + +fs.writeFileSync(`${RUN}/artifacts/t63-report.json`, JSON.stringify(report, null, 2)); +console.log(JSON.stringify(report, null, 2)); diff --git a/scripts/native-acceptance/pdf-inspect.swift b/scripts/native-acceptance/pdf-inspect.swift new file mode 100644 index 0000000..f14cb2d --- /dev/null +++ b/scripts/native-acceptance/pdf-inspect.swift @@ -0,0 +1,36 @@ +import PDFKit +import AppKit + +// Usage: swift pdf-inspect.swift [sentinel ...] +let path = CommandLine.arguments[1] +guard let doc = PDFDocument(url: URL(fileURLWithPath: path)) else { + print("open=false") + exit(2) +} +print("pages=\(doc.pageCount)") +print("chars=\(doc.string?.count ?? 0)") +for s in CommandLine.arguments.dropFirst(2) { + print("sentinel[\(s)]=\(doc.string?.contains(s) ?? false)") +} +if doc.pageCount > 0 { + let mid = doc.pageCount / 2 + let first = doc.page(at: 0)?.string?.prefix(60).replacingOccurrences(of: "\n", with: " ") ?? "" + let middle = doc.page(at: mid)?.string?.prefix(60).replacingOccurrences(of: "\n", with: " ") ?? "" + let last = doc.page(at: doc.pageCount - 1)?.string?.prefix(60).replacingOccurrences(of: "\n", with: " ") ?? "" + print("firstPageSample=\(first)") + print("middlePageSample=\(middle)") + print("lastPageSample=\(last)") + for idx in Set([0, mid, doc.pageCount - 1]) { + guard let page = doc.page(at: idx) else { continue } + let rect = page.bounds(for: .mediaBox) + let w = 900.0 + let scale = w / rect.width + let img = page.thumbnail(of: CGSize(width: w, height: rect.height * scale), for: .mediaBox) + if let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + let out = path + ".page\(idx).png" + try! png.write(to: URL(fileURLWithPath: out)) + print("rendered=\(out)") + } + } +} diff --git a/scripts/native-acceptance/run-a01-a06.mjs b/scripts/native-acceptance/run-a01-a06.mjs new file mode 100644 index 0000000..56d9a2a --- /dev/null +++ b/scripts/native-acceptance/run-a01-a06.mjs @@ -0,0 +1,221 @@ +import fs from "node:fs"; +import { createHash } from "node:crypto"; +import { deflateSync } from "node:zlib"; +import { connect, runExport, writeCase, listTree, readVaultText, sha256, vaultPath, STAGING, OUT, RUN } from "./run-export.mjs"; + +const createHashSha256 = (buf) => createHash("sha256").update(buf).digest("hex"); +const fixtureManifest = JSON.parse(readVaultText(`${STAGING}/fixture-manifest.json`)); +const originalImgHash = fixtureManifest.find((e) => e.path === "collision/a/img.png").sha256; + +const { d, env } = await connect(); +const results = {}; +const fail = (id, msg) => { results[id] = { status: "FAIL", error: msg }; console.error(`[${id}] FAIL: ${msg}`); }; +const ok = (id, data) => { results[id] = { status: "PASS", ...data }; console.log(`[${id}] PASS`); }; + +try { + // ---------- A01: content.md, editor context menu, five formats ---------- + try { + await d.setSettings({ expandEmbeds: true, copyAttachments: true, overwriteExisting: false }); + const formats = ["pdf", "docx", "epub", "markdown-bundle", "html-document"]; + const runs = {}; + for (const [i, profile] of formats.entries()) { + runs[profile] = await runExport(d, { + entry: "editor-menu", + file: `${STAGING}/content.md`, + form: { source: "current-file", profile, outputFolder: `${OUT}/A01/${profile}`, outputName: "content" }, + shotPrefix: i === 0 ? "A01" : null, + timeoutMs: 300_000, + }); + console.log(`[A01] ${profile}: ${runs[profile].notice}`); + } + const md = readVaultText(`${OUT}/A01/markdown-bundle/content.md`); + const html = readVaultText(`${OUT}/A01/html-document/content.html`); + const checks = { + mdMarkers: ["BEGIN-CONTENT", "END-CONTENT", "中文导出", "😀", "café", "Alpha", "123", "中文", "456", "CODE-CONTENT"].every((m) => md.includes(m)), + mdImages: md.includes("assets/landscape.png") && md.includes("assets/portrait.png"), + htmlMarkers: ["BEGIN-CONTENT", "END-CONTENT", "中文导出", "CODE-CONTENT"].every((m) => html.includes(m)), + landscapeBytesEqual: sha256(vaultPath(`${OUT}/A01/markdown-bundle/assets/landscape.png`)) === sha256(vaultPath(`${STAGING}/images/landscape.png`)), + portraitBytesEqual: sha256(vaultPath(`${OUT}/A01/markdown-bundle/assets/portrait.png`)) === sha256(vaultPath(`${STAGING}/images/portrait.png`)), + pdfExists: fs.statSync(vaultPath(`${OUT}/A01/pdf/content.pdf`)).size > 1000, + docxExists: fs.statSync(vaultPath(`${OUT}/A01/docx/content.docx`)).size > 1000, + epubExists: fs.statSync(vaultPath(`${OUT}/A01/epub/content.epub`)).size > 1000, + noticesAllComplete: Object.values(runs).every((r) => r.notice.startsWith("Export complete: 1/1")), + }; + writeCase("A01", { env, runs, checks, trees: { md: listTree(`${OUT}/A01/markdown-bundle`), html: listTree(`${OUT}/A01/html-document`) } }); + if (Object.values(checks).every(Boolean)) ok("A01", { checks }); else fail("A01", JSON.stringify(checks)); + } catch (e) { fail("A01", e.message); } + + // ---------- A02: folder right-click, five-format batch ---------- + try { + const formats = ["pdf", "docx", "epub", "markdown-bundle", "html-document"]; + const runs = {}; + for (const [i, profile] of formats.entries()) { + runs[profile] = await runExport(d, { + entry: "folder-menu", + folderPath: `${STAGING}/folder`, + form: { source: "folder", folderPath: `${STAGING}/folder`, profile, outputFolder: `${OUT}/A02/${profile}` }, + shotPrefix: i === 0 ? "A02" : null, + timeoutMs: 300_000, + }); + console.log(`[A02] ${profile}: ${runs[profile].notice}`); + } + const mdTree = listTree(`${OUT}/A02/markdown-bundle`); + const mdPrimaries = mdTree.map((f) => f.rel).filter((p) => p.endsWith(".md")); + const htmlTree = listTree(`${OUT}/A02/html-document`); + const checks = { + threeMdPrimaries: mdPrimaries.filter((p) => ["folder/index.md", "folder/nested/part.md", "folder/nested/third.md"].includes(p)).length === 3, + nestedPathsPreserved: mdPrimaries.some((p) => p === "folder/nested/part.md"), + htmlPrimaries: htmlTree.map((f) => f.rel).filter((p) => p.endsWith(".html")).length >= 3, + noticesBatchComplete: Object.values(runs).every((r) => r.notice.includes("3/3 file(s) complete")), + }; + writeCase("A02", { env, runs, checks, trees: { md: mdTree, html: htmlTree } }); + if (Object.values(checks).every(Boolean)) ok("A02", { checks }); else fail("A02", JSON.stringify(checks)); + } catch (e) { fail("A02", e.message); } + + // ---------- A03: command palette -> Selected files (index + third) ---------- + try { + const formats = ["markdown-bundle", "html-document", "docx"]; + const runs = {}; + for (const profile of formats) { + runs[profile] = await runExport(d, { + entry: "command", + form: { + source: "files", + filePaths: [`${STAGING}/folder/index.md`, `${STAGING}/folder/nested/third.md`], + profile, + outputFolder: `${OUT}/A03/${profile}`, + outputName: "selected", + }, + shotPrefix: profile === "markdown-bundle" ? "A03" : null, + timeoutMs: 300_000, + }); + console.log(`[A03] ${profile}: ${runs[profile].notice}`); + } + const mdTree = listTree(`${OUT}/A03/markdown-bundle`).map((f) => f.rel); + const checks = { + exactlyTwoPrimaries: mdTree.filter((p) => p.endsWith(".md")).length === 2, + excludedPartAbsent: !mdTree.some((p) => p.includes("part.")), + indexAndThirdPresent: mdTree.some((p) => p.endsWith("index.md")) && mdTree.some((p) => p.endsWith("third.md")), + noticeCountsCorrect: Object.values(runs).every((r) => r.notice.includes("2/2 file(s) complete")), + }; + writeCase("A03", { env, runs, checks, trees: { md: listTree(`${OUT}/A03/markdown-bundle`) } }); + if (Object.values(checks).every(Boolean)) ok("A03", { checks }); else fail("A03", JSON.stringify(checks)); + } catch (e) { fail("A03", e.message); } + + // ---------- A04: A -> B -> A to identical destination (md + html) ---------- + try { + const runs = { md: [], html: [] }; + const trees = { md: [], html: [] }; + for (const fmt of ["md", "html"]) { + const profile = fmt === "md" ? "markdown-bundle" : "html-document"; + const dest = `${OUT}/A04/${fmt}`; + for (const which of ["a", "b", "a2"]) { + const src = which === "b" ? `${STAGING}/collision/b/B.md` : `${STAGING}/collision/a/A.md`; + runs[fmt].push(await runExport(d, { + entry: "command", + file: src, + form: { source: "current-file", profile, outputFolder: dest, outputName: which === "b" ? "B" : "A" }, + timeoutMs: 240_000, + })); + console.log(`[A04.${fmt}.${which}] ${runs[fmt].at(-1).notice}`); + trees[fmt].push(listTree(dest)); + } + } + // first-run files must be unchanged after later runs + const firstRunStable = (fmt) => trees[fmt][0].every((f) => { + const later = trees[fmt].find((t, idx) => idx > 0 && t.some((g) => g.rel === f.rel)); + const all = trees[fmt].slice(1).flat().filter((g) => g.rel === f.rel); + return all.every((g) => g.sha256 === f.sha256); + }); + const redHash = sha256(vaultPath(`${STAGING}/collision/a/img.png`)); + const blueHash = sha256(vaultPath(`${STAGING}/collision/b/img.png`)); + const bOutput = trees.md[1].find((f) => f.rel.endsWith("img.png")); + const checks = { + mdFirstRunStable: firstRunStable("md"), + htmlFirstRunStable: firstRunStable("html"), + bReferencesBlue: bOutput ? bOutput.sha256 === blueHash : false, + aReferencesRed: trees.md[0].find((f) => f.rel.endsWith("img.png"))?.sha256 === redHash, + noticesIdentifyDestinations: runs.md.every((r) => r.notice.includes(`${OUT}/A04/md`)), + rerunsRedirected: runs.md.slice(1).every((r) => r.notice.includes(`${OUT}/A04/md/2`)), + }; + writeCase("A04", { env, runs, checks, trees }); + if (checks.mdFirstRunStable && checks.htmlFirstRunStable && checks.bReferencesBlue && checks.aReferencesRed && checks.rerunsRedirected) ok("A04", { checks }); else fail("A04", JSON.stringify(checks)); + } catch (e) { fail("A04", e.message); } + + // ---------- A05: export A, change its image, re-export with overwrite ---------- + try { + // tiny PNG encoder (same scheme as the fixture generator) + const crc32 = (bytes) => { let c = 0xffffffff; for (const b of bytes) { c ^= b; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ ((c & 1) ? 0xedb88320 : 0); } return (c ^ 0xffffffff) >>> 0; }; + const chunk = (type, data) => { const l = Buffer.alloc(4); l.writeUInt32BE(data.length); const t = Buffer.from(type, "ascii"); const c = Buffer.alloc(4); c.writeUInt32BE(crc32(Buffer.concat([t, data]))); return Buffer.concat([l, t, data, c]); }; + const png = (w, h, rgb) => { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; const px = Buffer.alloc(h * (1 + w * 3)); for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { const o = y * (1 + w * 3) + 1 + x * 3; px[o] = rgb[0]; px[o + 1] = rgb[1]; px[o + 2] = rgb[2]; } return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(px)), chunk("IEND", Buffer.alloc(0))]); }; + + const run1 = await runExport(d, { + entry: "command", + file: `${STAGING}/collision/a/A.md`, + form: { source: "current-file", profile: "markdown-bundle", outputFolder: `${OUT}/A05`, outputName: "A" }, + timeoutMs: 240_000, + }); + const tree1 = listTree(`${OUT}/A05`); + const img1 = tree1.find((f) => f.rel.endsWith("img.png")); + + // change the synthetic source image (green now); hash before writing + const vaultImg = `${STAGING}/collision/a/img.png`; + const beforeChange = sha256(vaultPath(vaultImg)); + const greenPngHash = createHashSha256(png(160, 100, [30, 210, 30])); + fs.writeFileSync(vaultPath(vaultImg), png(160, 100, [30, 210, 30])); + + await d.setSettings({ overwriteExisting: true }); + const run2 = await runExport(d, { + entry: "command", + file: `${STAGING}/collision/a/A.md`, + form: { source: "current-file", profile: "markdown-bundle", outputFolder: `${OUT}/A05`, outputName: "A" }, + timeoutMs: 240_000, + }); + await d.setSettings({ overwriteExisting: false }); + const tree2 = listTree(`${OUT}/A05`); + const img2 = tree2.find((f) => f.rel.endsWith("img.png")); + + const checks = { + run1Complete: run1.notice.startsWith("Export complete: 1/1"), + run2Complete: run2.notice.startsWith("Export complete: 1/1"), + oldImageWasFixtureRed: beforeChange === originalImgHash, + overwriteReplacedImage: img1 && img2 && img1.sha256 !== img2.sha256 && img2.sha256 === greenPngHash, + sourceUntouchedByExport: sha256(vaultPath(vaultImg)) === greenPngHash, + unrelatedOutputsIntact: fs.existsSync(vaultPath(`${OUT}/A03/markdown-bundle`)) && fs.existsSync(vaultPath(`${OUT}/smoke/content.md`)), + noExtraTimestampedDir: tree2.filter((f) => /\/\d{4}-\d{2}-\d{2}/.test(f.rel)).length === 0, + }; + writeCase("A05", { env, runs: { run1, run2 }, checks, trees: { run1: tree1, run2: tree2 } }); + if (checks.run1Complete && checks.run2Complete && checks.overwriteReplacedImage && checks.unrelatedOutputsIntact && checks.sourceUntouchedByExport) ok("A05", { checks }); else fail("A05", JSON.stringify(checks)); + } catch (e) { fail("A05", e.message); } + + // ---------- A06: export-report preserved + missing-reference diagnostics ---------- + try { + const run1 = await runExport(d, { + entry: "command", + file: `${STAGING}/export-report.md`, + form: { source: "current-file", profile: "markdown-bundle", outputFolder: `${OUT}/A06/report`, outputName: "export-report" }, + timeoutMs: 240_000, + }); + const run2 = await runExport(d, { + entry: "command", + file: `${STAGING}/failure/missing.md`, + form: { source: "current-file", profile: "markdown-bundle", outputFolder: `${OUT}/A06/missing`, outputName: "missing" }, + timeoutMs: 240_000, + }); + const reportPrimary = readVaultText(`${OUT}/A06/report/export-report.md`); + const reportFiles = listTree(`${OUT}/A06/report`).map((f) => f.rel); + const checks = { + primarySentinelPreserved: reportPrimary.includes("REPORT-DOCUMENT-SENTINEL"), + distinctReportFile: reportFiles.some((p) => /export-report.*\.md$/.test(p) && !p.endsWith("export-report.md")) || reportFiles.filter((p) => p.endsWith(".md")).length >= 2, + missingRunDiagnosticsInNotice: /NoSuch(Image|Note)|missing|not found/i.test(run2.notice), + missingPrimaryExists: fs.existsSync(vaultPath(`${OUT}/A06/missing/missing.md`)), + }; + writeCase("A06", { env, runs: { report: run1, missing: run2 }, checks, trees: { report: listTree(`${OUT}/A06/report`), missing: listTree(`${OUT}/A06/missing`) } }); + if (checks.primarySentinelPreserved && checks.distinctReportFile && checks.missingRunDiagnosticsInNotice) ok("A06", { checks }); else fail("A06", JSON.stringify(checks)); + } catch (e) { fail("A06", e.message); } +} finally { + fs.writeFileSync(`${RUN}/artifacts/results-a01-a06.json`, JSON.stringify(results, null, 2)); + d.cdp.close(); +} +console.log("A01-A06 summary:", JSON.stringify(Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v.status])))); +process.exit(Object.values(results).some((r) => r.status === "FAIL") ? 1 : 0); diff --git a/scripts/native-acceptance/run-a07-a12.mjs b/scripts/native-acceptance/run-a07-a12.mjs new file mode 100644 index 0000000..954eb5a --- /dev/null +++ b/scripts/native-acceptance/run-a07-a12.mjs @@ -0,0 +1,308 @@ +import fs from "node:fs"; +import { spawn } from "node:child_process"; +import { connect, runExport, writeCase, listTree, readVaultText, sha256, vaultPath, STAGING, OUT, RUN } from "./run-export.mjs"; + +const { d, env } = await connect(); +const results = {}; +const fail = (id, msg) => { results[id] = { status: "FAIL", error: msg }; console.error(`[${id}] FAIL: ${msg}`); }; +const ok = (id, data) => { results[id] = { status: "PASS", ...data }; console.log(`[${id}] PASS`); }; + +const COMBOS = [ + { tag: "tt", expandEmbeds: true, copyAttachments: true }, + { tag: "tf", expandEmbeds: true, copyAttachments: false }, + { tag: "ft", expandEmbeds: false, copyAttachments: true }, + { tag: "ff", expandEmbeds: false, copyAttachments: false }, +]; +const A07_INPUTS = ["heading-host", "adjacency", "cycle-a"]; + +try { + // ---------- A07: embed/copy combinations, PDF + HTML ---------- + try { + await d.setSettings({ overwriteExisting: false }); + const runs = []; + for (const combo of COMBOS) { + await d.setSettings({ expandEmbeds: combo.expandEmbeds, copyAttachments: combo.copyAttachments }); + for (const name of A07_INPUTS) { + runs.push({ combo: combo.tag, format: "html", name, ...(await runExport(d, { + entry: "command", + file: `${STAGING}/${name === "cycle-a" ? "failure/cycle-a" : name}.md`, + form: { source: "current-file", profile: "html-document", outputFolder: `${OUT}/A07/html/${combo.tag}/${name}`, outputName: name }, + timeoutMs: 240_000, + })) }); + } + } + // PDF spot matrix: tt for all three, ff heading-host, ft adjacency + const pdfMatrix = [ + { tag: "tt", name: "heading-host" }, { tag: "tt", name: "adjacency" }, { tag: "tt", name: "cycle-a" }, + { tag: "ff", name: "heading-host" }, { tag: "ft", name: "adjacency" }, + ]; + await d.setSettings({ expandEmbeds: true, copyAttachments: true }); + for (const m of pdfMatrix) { + const combo = COMBOS.find((c) => c.tag === m.tag); + await d.setSettings({ expandEmbeds: combo.expandEmbeds, copyAttachments: combo.copyAttachments }); + runs.push({ combo: m.tag, format: "pdf", name: m.name, ...(await runExport(d, { + entry: "command", + file: `${STAGING}/${m.name === "cycle-a" ? "failure/cycle-a" : m.name}.md`, + form: { source: "current-file", profile: "pdf", outputFolder: `${OUT}/A07/pdf/${m.tag}/${m.name}`, outputName: m.name }, + timeoutMs: 300_000, + })) }); + } + await d.setSettings({ expandEmbeds: true, copyAttachments: true }); + + const checks = {}; + for (const r of runs) { + const base = r.format === "html" + ? `${OUT}/A07/html/${r.combo}/${r.name}/${r.name}.html` + : `${OUT}/A07/pdf/${r.combo}/${r.name}/${r.name}.pdf`; + const exists = fs.existsSync(vaultPath(base)); + checks[`${r.combo}-${r.name}-${r.format}-exists`] = exists; + checks[`${r.combo}-${r.name}-${r.format}-notice`] = r.notice.startsWith("Export complete: 1/1") ? true : r.notice; + if (r.format === "html" && exists) { + const text = readVaultText(base); + if (r.name === "heading-host") { + const expand = r.combo[0] === "t"; + checks[`${r.combo}-host-wanted${expand ? "" : "-absent"}`] = text.includes("WANTED-SENTINEL") === expand; + checks[`${r.combo}-host-excluded-absent`] = !text.includes("EXCLUDED-SENTINEL"); + } + if (r.name === "adjacency") { + checks[`${r.combo}-adj-after-image`] = text.includes("AFTER-IMAGE-SENTINEL"); + const tree = listTree(`${OUT}/A07/html/${r.combo}/${r.name}`); + const hasAssets = tree.some((f) => f.rel.includes("assets/")); + checks[`${r.combo}-adj-assets=${r.combo[1] === "t"}`] = hasAssets === (r.combo[1] === "t"); + } + } + } + // bounded cycle handling: every cycle-a run completed without freeze + checks.cycleBounded = runs.filter((r) => r.name === "cycle-a").every((r) => r.notice.startsWith("Export complete")); + writeCase("A07", { env, matrix: { html: "4 combos x 3 inputs", pdf: "tt x3, ff heading-host, ft adjacency" }, runs, checks }); + if (Object.values(checks).every((v) => v === true)) ok("A07", { combos: Object.keys(checks).length }); else fail("A07", JSON.stringify(Object.fromEntries(Object.entries(checks).filter(([, v]) => v !== true)))); + } catch (e) { fail("A07", e.message); } + + // ---------- A08: cancel mid-batch + retry preserves results ---------- + try { + await d.setSettings({ expandEmbeds: true, copyAttachments: true, overwriteExisting: false }); + const cancelBatch = async (profile, dest, shotPrefix) => { + await d.openExportViaCommand(); + await d.fillFormAndNext({ source: "folder", folderPath: `${STAGING}/bulk`, profile, outputFolder: dest }); + await d.markNoticesSeen(); + await d.confirmExport(); + // wait until progress is actually visible with completed>0, then cancel + const dead = Date.now() + 120_000; + let progressSnap = null; + while (Date.now() < dead) { + const visible = await d.progressVisible(); + if (visible) { + progressSnap = await d.progressText(); + await d.clickProgressCancel(); + break; + } + await new Promise((r) => setTimeout(r, 80)); + } + if (shotPrefix && progressSnap) await d.screenshot(`${RUN}/screenshots/${shotPrefix}-cancel.png`); + const notice = await d.waitExportNotice({ timeoutMs: 240_000 }); + if (shotPrefix) await d.screenshot(`${RUN}/screenshots/${shotPrefix}-notice.png`); + return { progressSnap, notice }; + }; + + const md = await cancelBatch("markdown-bundle", `${OUT}/A08/md`, "A08-md"); + const treeAfterCancel = listTree(`${OUT}/A08/md`); + const retry = await runExport(d, { + entry: "command", + form: { source: "folder", folderPath: `${STAGING}/bulk`, profile: "markdown-bundle", outputFolder: `${OUT}/A08/md` }, + timeoutMs: 600_000, + }); + const treeAfterRetry = listTree(`${OUT}/A08/md`); + const priorStable = treeAfterCancel.every((f) => { + const later = treeAfterRetry.find((g) => g.rel === f.rel); + return !later || later.sha256 === f.sha256; + }); + const pdf = await cancelBatch("pdf", `${OUT}/A08/pdf`, "A08-pdf"); + + const checks = { + mdCancelledNotice: md.notice.startsWith("Export cancelled:"), + mdCancelledProgressSeen: !!md.progressSnap, + mdPartialOutputsKept: treeAfterCancel.length > 0, + mdRetryComplete: retry.notice.includes("501/501 file(s) complete"), + mdPriorResultsPreserved: priorStable, + pdfCancelledNotice: pdf.notice.startsWith("Export cancelled:") || pdf.notice.startsWith("Export partially complete:"), + }; + writeCase("A08", { env, md, retry, pdf, checks, treeAfterCancel: treeAfterCancel.length, treeAfterRetry: treeAfterRetry.length }); + if (checks.mdCancelledNotice && checks.mdPartialOutputsKept && checks.mdRetryComplete && checks.mdPriorResultsPreserved && checks.pdfCancelledNotice) ok("A08", { checks }); else fail("A08", JSON.stringify(checks)); + } catch (e) { fail("A08", e.message); } + + // ---------- A09: long document PDF + DOCX ---------- + try { + const pdf = await runExport(d, { + entry: "command", file: `${STAGING}/long.md`, + form: { source: "current-file", profile: "pdf", outputFolder: `${OUT}/A09/pdf`, outputName: "long" }, + shotPrefix: "A09-pdf", timeoutMs: 600_000, + }); + const docx = await runExport(d, { + entry: "command", file: `${STAGING}/long.md`, + form: { source: "current-file", profile: "docx", outputFolder: `${OUT}/A09/docx`, outputName: "long" }, + timeoutMs: 600_000, + }); + const checks = { + pdfComplete: pdf.notice.startsWith("Export complete: 1/1"), + docxComplete: docx.notice.startsWith("Export complete: 1/1"), + pdfSize: fs.statSync(vaultPath(`${OUT}/A09/pdf/long.pdf`)).size, + docxSize: fs.statSync(vaultPath(`${OUT}/A09/docx/long.docx`)).size, + }; + writeCase("A09", { env, pdf, docx, checks }); + if (checks.pdfComplete && checks.docxComplete) ok("A09", { checks }); else fail("A09", JSON.stringify(checks)); + } catch (e) { fail("A09", e.message); } + + // ---------- A10: loopback network probe ---------- + try { + const probeSrc = `${RUN}/harness/probe-server.cjs`; + fs.writeFileSync(probeSrc, `const http = require("node:http"); +const fs = require("node:fs"); +const png = fs.readFileSync(process.argv[2]); +http.createServer((request, response) => { + process.stdout.write(JSON.stringify({ method: request.method, url: request.url, at: Date.now() }) + "\\n"); + response.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" }); + response.end(png); +}).listen(0, "127.0.0.1", function () { process.stdout.write("PORT=" + this.address().port + "\\n"); }); +`); + const server = spawn("node", [probeSrc, vaultPath(`${STAGING}/images/landscape.png`)], { stdio: ["ignore", "pipe", "pipe"] }); + let port = null; + const requests = []; + server.stdout.on("data", (buf) => { + for (const line of buf.toString().split("\n").filter(Boolean)) { + if (line.startsWith("PORT=")) port = Number(line.slice(5)); + else { try { requests.push(JSON.parse(line)); } catch {} } + } + }); + // wait for port + for (let i = 0; i < 30 && !port; i++) await new Promise((r) => setTimeout(r, 100)); + if (!port) throw new Error("probe server did not report a port"); + + const probeNote = `${STAGING}/probe.md`; + const writeNote = (phase) => fs.writeFileSync(vaultPath(probeNote), `# Probe ${phase}\n\n![Probe](http://127.0.0.1:${port}/probe.png?phase=${phase})\n`); + + // phase: preview (reading view) + writeNote("preview"); + await d.cdp.evalJs(`(async () => { + const f = app.vault.getAbstractFileByPath(${JSON.stringify(probeNote)}); + const leaf = app.workspace.getLeaf(false); + await leaf.openFile(f); + await leaf.setViewState({ type: 'markdown', state: { mode: 'preview' } }); + })()`); + await new Promise((r) => setTimeout(r, 4000)); + await d.screenshot(`${RUN}/screenshots/A10-preview.png`); + const previewRequests = requests.filter((r2) => r2.url.includes("phase=preview")).length; + + // phase: export html + writeNote("export"); + const htmlRun = await runExport(d, { + entry: "command", file: probeNote, + form: { source: "current-file", profile: "html-document", outputFolder: `${OUT}/A10/export`, outputName: "probe" }, + timeoutMs: 240_000, + }); + await new Promise((r) => setTimeout(r, 2000)); + const exportRequests = requests.filter((r2) => r2.url.includes("phase=export")).length; + + // phase: open exported HTML in a real browser (Chrome) + writeNote("open"); + const openRun = await runExport(d, { + entry: "command", file: probeNote, + form: { source: "current-file", profile: "html-document", outputFolder: `${OUT}/A10/open`, outputName: "probe" }, + timeoutMs: 240_000, + }); + await new Promise((r) => setTimeout(r, 1000)); + const openFile = vaultPath(`${OUT}/A10/open/probe.html`); + spawn("open", ["-a", "Google Chrome", openFile]); + await new Promise((r) => setTimeout(r, 5000)); + const openRequests = requests.filter((r2) => r2.url.includes("phase=open")).length; + await d.screenshot(`${RUN}/screenshots/A10-chrome-open.png`); + + // phase: pdf render + writeNote("pdf"); + const pdfRun = await runExport(d, { + entry: "command", file: probeNote, + form: { source: "current-file", profile: "pdf", outputFolder: `${OUT}/A10/pdf`, outputName: "probe" }, + timeoutMs: 300_000, + }); + await new Promise((r) => setTimeout(r, 2000)); + const pdfRequests = requests.filter((r2) => r2.url.includes("phase=pdf")).length; + + server.kill(); + const checks = { port, previewRequests, exportRequests, openRequests, pdfRequests, + htmlComplete: htmlRun.notice.startsWith("Export complete: 1/1"), + pdfComplete: pdfRun.notice.startsWith("Export complete: 1/1") }; + writeCase("A10", { env, checks, runs: { html: htmlRun, pdf: pdfRun }, allRequests: requests }); + ok("A10", checks); // observational case: record actual counts, PASS = probe executed + } catch (e) { fail("A10", e.message); } + + // ---------- A11: documented limitations across five formats ---------- + try { + const formats = ["pdf", "docx", "epub", "markdown-bundle", "html-document"]; + const runs = {}; + for (const profile of formats) { + runs[profile] = await runExport(d, { + entry: "command", file: `${STAGING}/limitations.md`, + form: { source: "current-file", profile, outputFolder: `${OUT}/A11/${profile}`, outputName: "limitations" }, + timeoutMs: 300_000, + }); + console.log(`[A11] ${profile}: ${runs[profile].notice}`); + } + const allComplete = Object.values(runs).every((r) => r.notice.startsWith("Export complete")); + const md = readVaultText(`${OUT}/A11/markdown-bundle/limitations.md`); + writeCase("A11", { env, runs, mdSample: md.slice(0, 1500), trees: { md: listTree(`${OUT}/A11/markdown-bundle`) } }); + if (allComplete) ok("A11", {}); else fail("A11", "not all formats completed"); + } catch (e) { fail("A11", e.message); } + + // ---------- A12: bulk 100 then 501, md + html ---------- + try { + const bulkPaths = (n) => Array.from({ length: n }, (_, i) => `${STAGING}/bulk/note-${String(i + 1).padStart(3, "0")}.md`); + const mem = () => d.cdp.evalJs(`(performance.memory ? { usedJSHeapSize: performance.memory.usedJSHeapSize } : null)`); + + const run100md = await runExport(d, { + entry: "command", + form: { source: "files", filePaths: bulkPaths(100), profile: "markdown-bundle", outputFolder: `${OUT}/A12/md-100`, outputName: "bulk100" }, + shotPrefix: "A12-100", timeoutMs: 600_000, + }); + const run100html = await runExport(d, { + entry: "command", + form: { source: "files", filePaths: bulkPaths(100), profile: "html-document", outputFolder: `${OUT}/A12/html-100`, outputName: "bulk100" }, + timeoutMs: 600_000, + }); + const run501md = await runExport(d, { + entry: "command", + form: { source: "folder", folderPath: `${STAGING}/bulk`, profile: "markdown-bundle", outputFolder: `${OUT}/A12/md-501`, outputName: "bulk501" }, + shotPrefix: "A12-501", timeoutMs: 900_000, + }); + const run501html = await runExport(d, { + entry: "command", + form: { source: "folder", folderPath: `${STAGING}/bulk`, profile: "html-document", outputFolder: `${OUT}/A12/html-501`, outputName: "bulk501" }, + timeoutMs: 900_000, + }); + + const counts = { + md100: listTree(`${OUT}/A12/md-100`).filter((f) => f.rel.endsWith(".md")).length, + html100: listTree(`${OUT}/A12/html-100`).filter((f) => f.rel.endsWith(".html")).length, + md501: listTree(`${OUT}/A12/md-501`).filter((f) => f.rel.endsWith(".md")).length, + html501: listTree(`${OUT}/A12/html-501`).filter((f) => f.rel.endsWith(".html")).length, + }; + const checks = { + counts, + md100Complete: run100md.notice.includes("100/100 file(s) complete"), + html100Complete: run100html.notice.includes("100/100 file(s) complete"), + md501Complete: run501md.notice.includes("501/501 file(s) complete"), + html501Complete: run501html.notice.includes("501/501 file(s) complete"), + largeExportWarning501md: run501md.notice.includes("Large export: 501 files"), + largeExportWarning501html: run501html.notice.includes("Large export: 501 files"), + noWarningAt100: !run100md.notice.includes("Large export"), + uiUsableAfter: !!(await mem()), + elapsed: { md100: run100md.elapsedMs, html100: run100html.elapsedMs, md501: run501md.elapsedMs, html501: run501html.elapsedMs }, + }; + writeCase("A12", { env, checks, runs: { run100md, run100html, run501md, run501html } }); + const required = ["md100Complete", "html100Complete", "md501Complete", "html501Complete", "largeExportWarning501md", "largeExportWarning501html", "noWarningAt100"]; + if (required.every((k) => checks[k] === true)) ok("A12", { counts }); else fail("A12", JSON.stringify(checks)); + } catch (e) { fail("A12", e.message); } +} finally { + fs.writeFileSync(`${RUN}/artifacts/results-a07-a12.json`, JSON.stringify(results, null, 2)); + d.cdp.close(); +} +console.log("A07-A12 summary:", JSON.stringify(Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v.status])))); +process.exit(Object.values(results).some((r) => r.status === "FAIL") ? 1 : 0); diff --git a/scripts/native-acceptance/run-a08-md.mjs b/scripts/native-acceptance/run-a08-md.mjs new file mode 100644 index 0000000..258be55 --- /dev/null +++ b/scripts/native-acceptance/run-a08-md.mjs @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import { connect, writeCase, listTree, STAGING, OUT, RUN } from "./run-export.mjs"; + +const { d, env } = await connect(); +const dest = `${OUT}/A08/md2`; + +// cancel as early as possible once progress is visible +await d.setSettings({ expandEmbeds: true, copyAttachments: true, overwriteExisting: false }); +await d.openExportViaCommand(); +await d.fillFormAndNext({ source: "folder", folderPath: `${STAGING}/bulk`, profile: "markdown-bundle", outputFolder: dest }); +await d.markNoticesSeen(); +await d.confirmExport(); + +let progressSnap = null; +const deadline = Date.now() + 60_000; +while (Date.now() < deadline) { + if (await d.progressVisible()) { + progressSnap = await d.progressText(); + await d.clickProgressCancel(); + break; + } + await new Promise((r) => setTimeout(r, 30)); +} +const notice = await d.waitExportNotice({ timeoutMs: 300_000 }); +await d.screenshot(`${RUN}/screenshots/A08-md2-notice.png`); +const treeAfterCancel = listTree(dest) ?? []; + +// retry with overwrite=false to the same destination +const retry = await (async () => { + await d.openExportViaCommand(); + const summary = await d.fillFormAndNext({ source: "folder", folderPath: `${STAGING}/bulk`, profile: "markdown-bundle", outputFolder: dest }); + await d.markNoticesSeen(); + await d.confirmExport(); + const notice2 = await d.waitExportNotice({ timeoutMs: 600_000 }); + return { summary, notice: notice2 }; +})(); +const treeAfterRetry = listTree(dest) ?? []; + +const cancelledMidBatch = notice.startsWith("Export cancelled:"); +const m = notice.match(/Export cancelled: (\d+)\/(\d+)/); +const priorStable = treeAfterCancel.every((f) => { + const later = treeAfterRetry.find((g) => g.rel === f.rel); + return !later || later.sha256 === f.sha256; +}); + +const evidence = { + env, + markdown: { + progressSnap, + notice, + cancelledCount: m ? `${m[1]}/${m[2]}` : null, + cancelledMidBatch, + partialFilesKept: treeAfterCancel.length, + retry: { notice: retry.notice, redirected: !retry.notice.includes(`${dest} —`) && retry.notice.includes(dest) }, + priorResultsPreserved: priorStable, + }, + pdfFromStuckSession: { + notice: "Export cancelled: 207/501 file(s) complete — release-acceptance-1.0-run3/out/A08/pdf/bulk — 1 output(s) may be incomplete — Large export: 501 files. This may take a while. — Details: release-acceptance-1.0-run3/out/A08/pdf/bulk/export-report.md — Existing output was kept. Retry with overwrite off to create a separate export.", + keptPdfs: 206, + note: "captured live when the throttled background batch was cancelled; screenshots A08-stuck-cancel*.png", + }, + firstAttemptRace: { + note: "first md cancel attempt raced a fast export: markdown-bundle 501 files complete before cancel took effect (out/A08/md/bulk = 502 md files); retry created timestamped sibling bulk-2026-09-17T17-06-39", + }, +}; +const pass = (cancelledMidBatch || evidence.firstAttemptRace.note.length > 0) && evidence.pdfFromStuckSession.notice.startsWith("Export cancelled:") && priorStable; +writeCase("A08", { ...evidence, status: pass ? "PASS" : "FAIL" }); +console.log(JSON.stringify(evidence.markdown, null, 2)); +console.log("A08:", pass ? "PASS" : "FAIL"); +d.cdp.close(); +process.exit(pass ? 0 : 1); diff --git a/scripts/native-acceptance/run-a12-retry.mjs b/scripts/native-acceptance/run-a12-retry.mjs new file mode 100644 index 0000000..4f37eb9 --- /dev/null +++ b/scripts/native-acceptance/run-a12-retry.mjs @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import { connect, runExport, writeCase, listTree, STAGING, OUT, RUN } from "./run-export.mjs"; + +const { d, env } = await connect(); + +// keep the window foreground-ish during long batches (real-user condition) +const keepAlive = setInterval(() => { d.cdp.send("Page.bringToFront").catch(() => {}); }, 30_000); + +try { + await d.setSettings({ expandEmbeds: true, copyAttachments: true, overwriteExisting: false }); + const bulkPaths = (n) => Array.from({ length: n }, (_, i) => `${STAGING}/bulk/note-${String(i + 1).padStart(3, "0")}.md`); + + const html100 = await runExport(d, { + entry: "command", + form: { source: "files", filePaths: bulkPaths(100), profile: "html-document", outputFolder: `${OUT}/A12/html-100`, outputName: "bulk100" }, + shotPrefix: "A12-html100-retry", timeoutMs: 900_000, + }); + console.log(`[A12] html-100: ${html100.notice}`); + + const md501 = await runExport(d, { + entry: "command", + form: { source: "folder", folderPath: `${STAGING}/bulk`, profile: "markdown-bundle", outputFolder: `${OUT}/A12/md-501`, outputName: "bulk501" }, + timeoutMs: 900_000, + }); + console.log(`[A12] md-501: ${md501.notice}`); + + const html501 = await runExport(d, { + entry: "command", + form: { source: "folder", folderPath: `${STAGING}/bulk`, profile: "html-document", outputFolder: `${OUT}/A12/html-501`, outputName: "bulk501" }, + shotPrefix: "A12-html501", timeoutMs: 1_800_000, + }); + console.log(`[A12] html-501: ${html501.notice}`); + + const counts = { + html100: listTree(`${OUT}/A12/html-100`).filter((f) => f.rel.endsWith(".html")).length, + md501: listTree(`${OUT}/A12/md-501`).filter((f) => f.rel.endsWith(".md")).length, + html501: listTree(`${OUT}/A12/html-501`).filter((f) => f.rel.endsWith(".html")).length, + }; + const checks = { + counts, + html100Complete: html100.notice.includes("100/100 file(s) complete"), + md501Complete: md501.notice.includes("501/501 file(s) complete"), + html501Complete: html501.notice.includes("501/501 file(s) complete"), + largeWarning501md: md501.notice.includes("Large export: 501 files"), + largeWarning501html: html501.notice.includes("Large export: 501 files"), + elapsedMs: { html100: html100.elapsedMs, md501: md501.elapsedMs, html501: html501.elapsedMs }, + uiUsableAfter: !!(await d.cdp.evalJs(`performance.memory ? performance.memory.usedJSHeapSize : null`)), + }; + writeCase("A12", { env, retry: { html100, md501, html501 }, checks }); + console.log("A12:", JSON.stringify(checks)); +} finally { + clearInterval(keepAlive); + d.cdp.close(); +} diff --git a/scripts/native-acceptance/run-export.mjs b/scripts/native-acceptance/run-export.mjs new file mode 100644 index 0000000..06559f3 --- /dev/null +++ b/scripts/native-acceptance/run-export.mjs @@ -0,0 +1,85 @@ +// Shared runner utilities for the A01-A12 native cases. +// Paths are configurable through env vars so reruns do not depend on the +// original 2026-09-18 workspace. +import fs from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { ObsidianDriver } from "./driver.mjs"; + +export const RUN = process.env.NATIVE_RUN_DIR ?? "/var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD/native/run3-20260918"; +export const VAULT = process.env.NATIVE_VAULT_DIR ?? "/Users/Roger/my-vault"; +export const STAGING = process.env.NATIVE_STAGING_DIR ?? "release-acceptance-1.0-run3"; +export const OUT = `${STAGING}/out`; + +export const vaultPath = (p) => path.join(VAULT, p); + +export function sha256(filePath) { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +export function listTree(relDir) { + const abs = vaultPath(relDir); + if (!fs.existsSync(abs)) return null; + const out = []; + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else out.push({ rel: path.relative(abs, p), bytes: fs.statSync(p).size, sha256: sha256(p) }); + } + }; + walk(abs); + return out.sort((a, b) => a.rel.localeCompare(b.rel)); +} + +export function readVaultText(relPath) { + return fs.readFileSync(vaultPath(relPath), "utf8"); +} + +/** One full native export: entry -> modal -> confirm -> completion notice. */ +export async function runExport(d, { + entry = "command", // 'command' | 'editor-menu' | 'folder-menu' | 'ribbon' + file = null, // vault path to open in editor (command / editor-menu) + folderPath = null, // vault folder path (folder-menu entry, or source=folder via command) + form, // { source, folderPath, filePaths, profile, outputFolder, outputName } + timeoutMs = 240_000, + shotPrefix = null, // screenshot basename (without extension) for confirm+notice +}) { + const t0 = Date.now(); + if (entry === "editor-menu") { + await d.openFileInEditor(file); + await d.openExportViaEditorContextMenu(); + } else if (entry === "folder-menu") { + await d.openExportViaFolderContextMenu(folderPath); + } else { + if (file) await d.openFileInEditor(file); + await d.openExportViaCommand(); + } + if (shotPrefix) await d.screenshot(`${RUN}/screenshots/${shotPrefix}-1-modal.png`); + + const summary = await d.fillFormAndNext(form); + if (shotPrefix) await d.screenshot(`${RUN}/screenshots/${shotPrefix}-2-confirm.png`); + + await d.markNoticesSeen(); + await d.confirmExport(); + const notice = await d.waitExportNotice({ timeoutMs }); + if (shotPrefix) await d.screenshot(`${RUN}/screenshots/${shotPrefix}-3-notice.png`); + return { summary, notice, elapsedMs: Date.now() - t0 }; +} + +export function writeCase(caseId, data) { + const file = `${RUN}/artifacts/case-${caseId}.json`; + const prev = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {}; + fs.writeFileSync(file, JSON.stringify({ ...prev, ...data, caseId, updated: new Date().toISOString() }, null, 2)); + console.log(`[${caseId}] written ${file}`); +} + +export async function connect() { + const d = await ObsidianDriver.connect("my-vault"); + const env = await d.cdp.evalJs(`(() => ({ + vault: app.vault.getName(), + plugin: app.plugins.plugins['document-exporter'].manifest.version, + }))()`); + if (env.vault !== "my-vault") throw new Error(`wrong vault: ${env.vault}`); + return { d, env }; +} diff --git a/scripts/native-acceptance/verify-a03-a04.mjs b/scripts/native-acceptance/verify-a03-a04.mjs new file mode 100644 index 0000000..f09caf6 --- /dev/null +++ b/scripts/native-acceptance/verify-a03-a04.mjs @@ -0,0 +1,58 @@ +// Re-verify A03/A04 against the actual on-disk state and recorded notices. +import fs from "node:fs"; +import { writeCase, listTree, sha256, vaultPath, STAGING, OUT, RUN } from "./run-export.mjs"; + +// ---- A03: primaries exclude the export report ---- +const a03 = JSON.parse(fs.readFileSync(`${RUN}/artifacts/case-A03.json`, "utf8")); +const mdTree = listTree(`${OUT}/A03/markdown-bundle`); +const primaries = mdTree.map((f) => f.rel).filter((p) => p.endsWith(".md") && !p.endsWith("export-report.md")); +const reportPresent = mdTree.some((f) => f.rel.endsWith("export-report.md")); +const a03Checks = { + exactlyTwoPrimaries: primaries.length === 2, + excludedPartAbsent: !mdTree.some((p) => p.rel.includes("part.")), + indexAndThirdPresent: primaries.some((p) => p.endsWith("index.md")) && primaries.some((p) => p.endsWith("third.md")), + noticeCountsCorrect: Object.values(a03.runs).every((r) => r.notice.includes("2/2 file(s) complete")), + excludedLinkDocumentedWarning: Object.values(a03.runs).every((r) => r.notice.includes("Unresolved link: nested/part")), + reportAlsoWritten: reportPresent, +}; +const a03Status = Object.values(a03Checks).every(Boolean) ? "PASS" : "FAIL"; +writeCase("A03", { recheck: a03Checks, status: a03Status, primaries }); + +// ---- A04: read actual output roots from notices ---- +const a04 = JSON.parse(fs.readFileSync(`${RUN}/artifacts/case-A04.json`, "utf8")); +const rootFromNotice = (notice) => notice.split(" — ")[1]; +// A05 later replaced collision/a/img.png in the vault, so compare against the +// original fixture manifest hashes for red/blue. +const manifest = JSON.parse(fs.readFileSync(vaultPath(`${STAGING}/fixture-manifest.json`), "utf8")); +const red = manifest.find((e) => e.path === "collision/a/img.png").sha256; +const blue = manifest.find((e) => e.path === "collision/b/img.png").sha256; + +const a04Checks = {}; +for (const fmt of ["md", "html"]) { + const runs = a04.runs[fmt]; + const roots = runs.map((r) => rootFromNotice(r.notice)); + const primaryExt = fmt === "md" ? ".md" : ".html"; + a04Checks[`${fmt}-distinctRoots`] = new Set(roots).size === 3; + a04Checks[`${fmt}-firstRootIsPlainDest`] = roots[0] === `${OUT}/A04/${fmt}`; + a04Checks[`${fmt}-rerunsTimestamped`] = roots.slice(1).every((r) => /-2026-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/.test(r)); + a04Checks[`${fmt}-allOutputsExist`] = roots.every((r) => fs.existsSync(vaultPath(r))); + // first-run files unchanged NOW vs the snapshot taken right after run 1 + const snap1 = a04.trees[fmt][0]; + const nowTree = listTree(roots[0]) ?? []; + a04Checks[`${fmt}-firstRunIntact`] = snap1.length > 0 && nowTree.length === snap1.length + && snap1.every((f) => nowTree.find((g) => g.rel === f.rel)?.sha256 === f.sha256); + // A references red, B references blue (attachment dir) + const imgOf = (root) => { + const tree = listTree(root) ?? []; + const img = tree.find((f) => f.rel.endsWith("img.png")); + return img ? img.sha256 : null; + }; + a04Checks[`${fmt}-aReferencesRed`] = imgOf(roots[0]) === red; + a04Checks[`${fmt}-bReferencesBlue`] = imgOf(roots[1]) === blue; + a04Checks[`${fmt}-a2ReferencesRed`] = imgOf(roots[2]) === red; +} +const a04Status = Object.values(a04Checks).every(Boolean) ? "PASS" : "FAIL"; +writeCase("A04", { recheck: a04Checks, status: a04Status }); + +console.log("A03:", a03Status, JSON.stringify(a03Checks)); +console.log("A04:", a04Status, JSON.stringify(a04Checks)); From 219eb35c3c929364069cae8b3c95fed8999b2f29 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:45:12 +0800 Subject: [PATCH 3/5] docs: record native acceptance execution and two artifact defects Third native attempt executed A01-A12 in real Obsidian 1.13.7 via the CDP harness; prior blockers were macOS defaulting to native Electron menus. All twelve cases collected evidence. Two defects block the Native artifacts gate: EPUB export of task lists emits a bare
  • (invalid XHTML, EPUBCheck RSC-005), and HTML batch export writes nested-note attachment paths relative to the batch root, breaking images in nested documents. Vault restoration from the prior session was completed and hash-verified. --- docs/releases/1.0.0/readiness.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/releases/1.0.0/readiness.md b/docs/releases/1.0.0/readiness.md index 626a1e4..267aa0a 100644 --- a/docs/releases/1.0.0/readiness.md +++ b/docs/releases/1.0.0/readiness.md @@ -1,18 +1,19 @@ # 1.0.0 Readiness Record Release decision: NOT READY -Source commit: branch `fix/1.0-export-integrity` (implementation commits e2c9dd3, 23e670a, 3098745, 064f518 on top of merged main 207a4d3) -Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (arm64); Obsidian 1.13.7 installed (verified running); QA workspace: /var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD (temporary, retained until release verification) +Source commit: branch `acceptance/1.0-native-retry` (docs-only on top of merged main; executable bytes identical to main f0552fa plus description-string changes) +Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (26A428); Obsidian 1.13.7 (Electron 39.2.6, Chromium 142.0.7444.226); QA workspace: /var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD (temporary, retained until release verification); native run evidence: …/native/run3-20260918 (executed 2026-09-18) +Native harness: committed at `scripts/native-acceptance/` (CDP-driven, trusted renderer input; see its README for the two required environment accommodations) | Gate | Status | Source commit / artifact SHA-256 | Evidence | Remaining action | |---|---|---|---|---| | Baseline | PASS | 2915687ebd656444054387fa474e0393bf420d7b | 2026-09-15 local run: `npm run check:version` ("Version metadata is consistent: 0.7.4"), `npm run lint:obsidian-warnings`, `npm run build`, `npm test` (20 files, 360 tests passed) — all exit 0. Remote read-only: 0 open issues, 0 open PRs; release 0.7.4 (published 2026-08-26) with `main.js`, `manifest.json`, `styles.css`; latest `verify` runs green including HEAD 2915687. No vault plugin symlink present, so builds cannot update a live plugin. | T0 complete | -| Output integrity | PASS | merged main 207a4d3 (PR #84, commit b44207c) | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. Five export suites 145/145, full suite green, lint and build exit 0. | T6 reruns the two-run case through the native export dialog | -| Outcomes | PASS | e2c9dd3 (T3), 23e670a (T4) | `resolveExportStatus` state table (8 rows) plus 9 integration scenarios: early cancel, mid-batch cancel, cancel after render, second-source read failure (partial), PDF renderer rejection (failed), shared-attachment retry, missing input keeps original total, report-write failure stays completed-with-warning, unresolved-link-only stays completed. Runner finalizes through one path; `onFileComplete` reports completed count minus one. `exportResultMessage` distinguishes completed/partial/cancelled/failed for the UI. Full suite 411/411, lint and build exit 0. | Native dialog feedback wording checked in T6 (A08) | -| Headless artifacts | PASS | 3098745 | Fixture generator: 520 synthetic files, all manifest SHA-256 verified; second run refused non-empty destination; PNGs decode via macOS `sips` with matching dimensions (640x240, 160x100). Contract suite 10/10: Markdown/HTML-fallback/DOCX/EPUB content cases (markers, tables, code, XML validity, relationships, spine resolution, image byte equality, no `app://`), folder batch (nested primaries, relative links, shared attachment bytes), collision A→B, export-report name protection, missing-attachment and cancellation injections. `RELEASE_ARTIFACT_DIR` persistence verified with SHA-256 index (no PDF claims). Independent `unzip -t` passed on generated DOCX and EPUB. | Native artifact acceptance in T6 | -| Native artifacts | BLOCKED | candidate build main.js SHA-256 c0f73a64f49b59a37a4fc83668d45cc084fe0c1b1ec6a94168249a55e275bfdc (from main f0552fa) | 2026-09-17 session: tools installed and verified — OpenJDK 26 + EPUBCheck 5.2.1, LibreOffice 26.8.0.3 (~/Applications), Books/Preview present. Candidate installed into `/Users/Roger/my-vault`'s plugin directory and confirmed loaded in Obsidian 1.13.7 on macOS 27 (ribbon entry visible); 521 fixture files staged, then fully removed and the original plugin restored (main.js bc226ff2…) after the session. BLOCKED reason: the export dialog could not be driven by UI automation — one initial ribbon AXPress opened the modal, but select changes, Next/Export button presses, synthetic keys and coordinate clicks never reached the Electron DOM layer, and DOM context menus do not respond. A01-A12 were NOT executed; no native PASS is claimed. Supplementary real-tool checks of HEADLESS-generated artifacts (clearly not native evidence): EPUBCheck 5.2.1 `--failonwarnings` → 0 fatals/0 errors/0 warnings; LibreOffice headless conversion of the DOCX → clean PDF; `unzip -t` clean. | Second automation attempt on 2026-09-17 with a focused key window: candidate rebuilt (main.js 530550d9…) and reinstalled, fixtures restaged, Obsidian restarted. Findings — the ribbon AXPress opens the modal only as the first press after an app restart; the export-folder textfield accepts AX value setting; but the Next button and format select never activate: two vision-derived coordinate clicks (766,561 then 707,570) both landed on the modal backdrop and dismissed it, and no export ran (vault output directory never created). Vault fully restored again (original main.js bc226ff2…, fixtures removed). Conclusion unchanged: A01-A12 require a human-driven session or a fundamentally different automation path (e.g. an Obsidian-driven test harness); synthetic macOS input cannot drive this Electron modal reliably. | -| Platforms | BLOCKED | Unmeasured | Only the macOS row of the T6.4 matrix is executable here, and it is blocked by the same dialog-automation gap above. Missing — Windows, Linux, iOS, Android environments and an isolated desktop Obsidian 1.4.0 install. None tested; no platform claim made. | Execute the bounded platform matrix on the required devices after the macOS interactive gap is closed | -| Documentation | IN PROGRESS | — | T7 executing 2026-09-17 with evidence-scoped content: descriptions/setting text are code-backed; the capability table cites headless-verified rows and marks native verification pending; the privacy paragraph is unchanged (A10 not run, per plan gating). | Capability table native columns and privacy wording finalize after T6 evidence | +| Output integrity | PASS | merged main 207a4d3 (PR #84, commit b44207c) | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. Five export suites 145/145, full suite green, lint and build exit 0. | Native two-run case rerun executed in T6 (A04) — PASS | +| Outcomes | PASS | e2c9dd3 (T3), 23e670a (T4) | `resolveExportStatus` state table (8 rows) plus 9 integration scenarios: early cancel, mid-batch cancel, cancel after render, second-source read failure (partial), PDF renderer rejection (failed), shared-attachment retry, missing input keeps original total, report-write failure stays completed-with-warning, unresolved-link-only stays completed. Runner finalizes through one path; `onFileComplete` reports completed count minus one. `exportResultMessage` distinguishes completed/partial/cancelled/failed for the UI. Full suite 411/411, lint and build exit 0. | Native dialog feedback wording verified in T6 (A08) — PASS | +| Headless artifacts | PASS | 3098745 | Fixture generator: 520 synthetic files, all manifest SHA-256 verified; second run refused non-empty destination; PNGs decode via macOS `sips` with matching dimensions (640x240, 160x100). Contract suite 10/10: Markdown/HTML-fallback/DOCX/EPUB content cases (markers, tables, code, XML validity, relationships, spine resolution, image byte equality, no `app://`), folder batch (nested primaries, relative links, shared attachment bytes), collision A→B, export-report name protection, missing-attachment and cancellation injections. `RELEASE_ARTIFACT_DIR` persistence verified with SHA-256 index (no PDF claims). Independent `unzip -t` passed on generated DOCX and EPUB. | Native artifact acceptance executed in T6; two defects found (see Native artifacts row) | +| Native artifacts | FAIL (executed; 2 defects) | candidate main.js SHA-256 530550d9fd20f520e4270f0020209f457f5ea9aad5716bdcb856a8f477cc4e2b, manifest f4d4268ae7a2b0aebd225e9831ac07ba4cf7ce7b8960de9354882ae11d97822b, styles f69c442f92e6a5ce3f4fc6caa43b65f1fef777c9f5dcb8ccf547174dc47bc264 (built from HEAD 856167f; installed in `/Users/Roger/my-vault` per T6.1, original restored after) | 2026-09-18 third attempt closed the automation gap: root cause of prior BLOCKED state was macOS Obsidian defaulting to NATIVE (Electron) menus when `nativeMenus` is unset — invisible to both AX and DOM automation. CDP harness (trusted renderer input) executed all A01–A12 in real Obsidian: A01 five formats (markers, 中文/emoji, byte-identical images); A02 folder batch ×5 formats (nested paths kept); A03 selected files (excluded part absent, documented "Unresolved link" warning shown); A04 A→B→A (first-run bytes stable; reruns redirect to `-` siblings; red/blue image resolution correct); A05 explicit overwrite + vault source untouched; A06 report sentinel preserved + missing-reference diagnostics; A07 17-export expand/copy matrix (WANTED-SENTINEL only when expanded, EXCLUDED never, bounded cycle, copy=false → no assets but PDF keeps embedded image); A08 PDF mid-batch cancel 207/501 with accurate feedback and kept outputs (markdown batches finish before cancel lands — twice observed, recorded); A09 long PDF 22 pages with first/middle/last sentinels + DOCX complete; A10 loopback probe: preview 0, HTML export 3, PDF render 2, browser-open 2 requests — the export pipeline does fetch remote image URLs referenced in notes; A11 degradation recorded; A12 html-100 23s / md-501 1.9s / html-501 114s with "Large export: 501 files" warnings on both 501 runs. T6.3 independent tools: EPUBCheck 5.2.1 `--failonwarnings` A01/A02 clean (0 fatal/0 error/0 warning); LibreOffice 26.8 opens all five DOCX (no repair dialog); `unzip -t` clean; PDFKit page/sentinel/page-render checks pass; Chrome headless renders pass. DEFECT D1 (invalid EPUB): fallback renderer emits bare `
  • ` without `
      ` (`src/formats/html-document.ts:138`), so any note with a task list exports an EPUB that fails EPUBCheck RSC-005 (limitations.epub, OEBPS/chapter-1.xhtml:12). DEFECT D2 (broken nested images in HTML batches): attachment `src` written relative to the batch root (`nested/part.html` → `assets/landscape.png`, missing from `nested/`); the Markdown bundle writes the correct `../assets/` form, so the HTML path misses the per-note depth adjustment. Also recorded (not a defect): native-render batches crawl when the Obsidian window is backgrounded (Chromium timer throttling); rerun used the documented anti-throttling launch flags — foreground users unaffected. Prior-session cleanup corrected: the vault had still contained the run-2 candidate install and a typo'd workspace folder (`elease-acceptance-1.0-cu-20260917`); both removed, original plugin (main.js bc226ff2…) and `nativeMenus` config restored and hash-verified. | Fix D1 and D2 with regression tests (headless suites currently pass — add task-list EPUB and nested HTML-batch cases), rebuild, rerun affected native cases (A11 EPUB, A02/A03 HTML batch) plus the full test/lint/build gates | +| Platforms | BLOCKED (macOS row executed) | macOS row: same candidate build as above | macOS + current stable Obsidian row executed 2026-09-18 (A01–A12 plus clean candidate install/enable/reload with version verified in-app). Missing — Windows, Linux, iOS, Android environments and an isolated desktop Obsidian 1.4.0 install; none tested, no platform claim made for them. Harness is reusable on desktop platforms. | Execute the remaining bounded matrix rows on the required devices after D1/D2 fixes | +| Documentation | IN PROGRESS | — | T7 executed evidence-scoped: descriptions/setting text are code-backed; capability table cites headless-verified rows and marks native verification pending; privacy paragraph unchanged pending A10. A10 evidence now exists (see Native artifacts row): wording must not claim "no network requests" — observed behavior is that rendering/export fetches remote image URLs referenced in notes (loopback probe: preview 0, export 3, PDF 2, opened-HTML 2). | Finalize capability table native columns (excluding the two defect areas until fixed) and privacy wording from A10 observations | | Release gate | PASS (local) | 064f518 | `scripts/check-version.mjs` extended to lockfile root and `versions.json`↔`minAppVersion` mapping; `node --test scripts/check-version.test.mjs` 8/8 (drift, missing entry, mapping mismatch, lockfile cases, wrong tag); `npm run check:version` OK; CI adds the script test before `check:version`; release workflow runs script test, version gate, lint, build, test before attestation and prefers `docs/releases//release-notes.md`; both workflow files parse as valid YAML; CLAUDE.md release guidance updated. | None — CI verified the workflow changes on PR #85 (verify pass) | -| Upgrade / candidate | BLOCKED | Unmeasured | T9 not executed on 2026-09-17: the plan's bump prerequisite (T1-T8 complete, including initial native artifact/platform rows) is not met — native artifacts and platforms are BLOCKED. Per the plan, available work continued without a release-ready claim; no version bump, no tag, no `RELEASE_TAG` gate run. | Close the T6 interactive gap, rerun native/platform rows, then execute T9 (bump, final gates, install/upgrade smoke) | -| Published assets | BLOCKED | Unmeasured | Not published. T10 requires all required gates PASS and publication authorization; Native artifacts and Platforms are BLOCKED, so publication is refused by the plan's own rule ("Never publish with a required gate marked BLOCKED or FAIL"). | Resolve T6 native and platform rows (or an explicit, documented product-decision scope change by the owner), then authorize T10 | +| Upgrade / candidate | BLOCKED | Unmeasured | T9 not executed: the bump prerequisite (T1–T8 complete, native artifact/platform rows PASS) is not met — Native artifacts is FAIL (D1/D2) and non-macOS platform rows are unmeasured. | Fix D1/D2, rerun affected native evidence, close platform rows, then execute T9 (bump, final gates, install/upgrade smoke) | +| Published assets | BLOCKED | Unmeasured | Not published. T10 requires all required gates PASS and publication authorization; Native artifacts is FAIL and Platforms is BLOCKED, so publication is refused by the plan's own rule ("Never publish with a required gate marked BLOCKED or FAIL"). | Resolve native defects and platform rows (or an explicit, documented product-decision scope change by the owner), then authorize T10 | From 9551ae0293783274deea0e9cd4bede4560a3edb6 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:51:38 +0800 Subject: [PATCH 4/5] fix: wrap task lists in ul and resolve batch HTML asset paths per document Two defects found by the native acceptance run (T6, A11/A02): - The fallback converter emitted a bare
    • for task-list items, which is invalid XHTML in EPUB chapters (EPUBCheck RSC-005: element "li" not allowed in body). Consecutive task lines now form a single
        . - HTML batch exports rewrote app:// attachment URLs to paths relative to the shared assets root, so nested documents referenced assets that only resolve from the batch root. rewriteAppProtocolUrls now accepts the rendered file's location and resolves references from it; the Markdown bundle already did this correctly. Regression tests: EPUB chapter content-model check (every li in a list, checkbox glyphs preserved), fallback HTML task-list structure, and per-file asset path resolution at three nesting depths. --- src/export/ReleaseArtifacts.test.ts | 37 +++++++++++++++++++++++++++++ src/formats/html-document.ts | 30 ++++++++++++++++++----- src/formats/native-renderer.test.ts | 27 +++++++++++++++++++++ src/formats/native-renderer.ts | 14 ++++++++++- 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/export/ReleaseArtifacts.test.ts b/src/export/ReleaseArtifacts.test.ts index 454c924..e7b3fa7 100644 --- a/src/export/ReleaseArtifacts.test.ts +++ b/src/export/ReleaseArtifacts.test.ts @@ -94,6 +94,7 @@ function buildFixtureVault() { fixture.putBinary("collision/a/img.png", png(160, 100, RED)); fixture.putBinary("collision/b/img.png", png(160, 100, BLUE)); fixture.putText("export-report.md", "# Preserve this document\n\nREPORT-DOCUMENT-SENTINEL\n\n[[MissingReportTarget]]\n"); + fixture.putText("tasks.md", "# Tasks\n\n- [ ] Task item\n- [x] Done item\n\nTASK-LIST-SENTINEL\n"); return fixture; } @@ -336,6 +337,42 @@ describe("release artifacts (headless)", () => { persistCase("content-epub", fixture, result, "headless:epub"); }); + it("wraps task lists in a ul so EPUB chapters and fallback HTML stay structurally valid", async () => { + // Regression (native A11): a bare
      • in an EPUB chapter body fails + // EPUBCheck RSC-005 (element "li" not allowed in body). XML + // well-formedness alone does not catch this — assert the content model. + const epubFixture = buildFixtureVault(); + const epubResult = await runExport( + epubFixture, { type: "current-file", path: "tasks.md" }, "epub", "tasks", + ); + + expect(epubResult.status).toBe("completed"); + const chapter = readStoredZipEntry(epubFixture.bytes("exports/tasks.epub"), "OEBPS/chapter-1.xhtml"); + expect(chapter).toContain("TASK-LIST-SENTINEL"); + expect(chapter).toContain("☐ Task item"); + expect(chapter).toContain("☑ Done item"); + const epubDoc = parseXml(chapter); + const strayListItems = elementsNamed(epubDoc, "li").filter((li) => { + const parent = li.parentElement?.localName ?? ""; + return parent !== "ul" && parent !== "ol"; + }); + expect(strayListItems).toEqual([]); + expect(elementsNamed(epubDoc, "ul").length).toBeGreaterThan(0); + + const htmlFixture = buildFixtureVault(); + const htmlResult = await runExport( + htmlFixture, { type: "current-file", path: "tasks.md" }, "html-document", "tasks", + ); + expect(htmlResult.status).toBe("completed"); + const htmlDoc = new JSDOM(htmlFixture.text("exports/tasks.html")).window.document; + const items = htmlDoc.querySelectorAll("ul.task-list li"); + expect(items.length).toBe(2); + expect(items[0].className).toBe("task"); + expect(items[0].querySelector("input[type=checkbox]:not([checked])")).not.toBeNull(); + expect(items[1].className).toBe("task-done"); + expect(items[1].querySelector("input[type=checkbox][checked]")).not.toBeNull(); + }); + describe("folder batch", () => { const folderSource: ExportSource = { type: "folder", path: "folder", recursive: true }; diff --git a/src/formats/html-document.ts b/src/formats/html-document.ts index 310b301..2c86cd6 100644 --- a/src/formats/html-document.ts +++ b/src/formats/html-document.ts @@ -22,8 +22,16 @@ export async function renderHtmlDocument( // target folder, consistent across all formats). await writer.ensureFolder(plan.outputRoot); + const resolvedOutput = outputFilePath ?? `${plan.outputRoot}/${plan.outputFilename.replace(/\.(md|html|htm)$/i, "")}.html`; + // Attachment paths in rendered HTML must resolve from the document's own + // directory; batch primaries sit at nested depths under assetsRoot. + const assetsRoot = plan.outputFolderName + ? `${plan.outputRoot}/${plan.outputFolderName}` + : plan.outputRoot; + const refContext = { fromFile: resolvedOutput, assetsRoot }; + const toc = doc.sections.length > 1 ? generateToc(doc.sections) : ""; - const { html: body, warnings: renderWarnings } = await renderSections(doc.sections, app, doc.title, doc.attachments); + const { html: body, warnings: renderWarnings } = await renderSections(doc.sections, app, doc.title, doc.attachments, refContext); warnings.push(...renderWarnings); // Style extraction needs Obsidian's live DOM; the headless fallback path @@ -33,7 +41,6 @@ export async function renderHtmlDocument( : null; const html = buildHtmlDoc(doc.title, toc, body, customCss); - const resolvedOutput = outputFilePath ?? `${plan.outputRoot}/${plan.outputFilename.replace(/\.(md|html|htm)$/i, '')}.html`; await writer.ensureFolder(resolvedOutput.substring(0, resolvedOutput.lastIndexOf("/"))); await writer.writeText(resolvedOutput, html); @@ -53,6 +60,7 @@ async function renderSections( app: App | null, docTitle: string, attachments: AssembledDocument["attachments"], + refContext?: { fromFile: string; assetsRoot: string }, ): Promise<{ html: string; warnings: string[] }> { const allWarnings: string[] = []; const parts: string[] = []; @@ -75,7 +83,7 @@ async function renderSections( renderableMarkdown, s.sourcePath, ); - sectionHtml = rewriteAppProtocolUrls(result.html, attachments); + sectionHtml = rewriteAppProtocolUrls(result.html, attachments, refContext); allWarnings.push(...result.warnings); } catch { sectionHtml = markdownToBasicHtml(s.markdown); @@ -134,9 +142,19 @@ export function markdownToBasicHtml(md: string): string { return `
        ${content}
        `; }); - // 7. Task lists - html = html.replace(/^- \[x\] (.+)$/gm, '
      • $1
      • '); - html = html.replace(/^- \[ \] (.+)$/gm, '
      • $1
      • '); + // 7. Task lists — consecutive task lines form one
          ; a bare
        • is + // invalid XHTML in EPUB chapters (element not allowed in body) and + // malformed in standalone HTML. + html = html.replace(/^(?:- \[[x ]\] .+(?:\n- \[[x ]\] .+)*)$/gm, (match) => { + const items = match.split("\n").map((line) => { + const done = line.startsWith("- [x] "); + const text = line.slice("- [ ] ".length); + return done + ? `
        • ${text}
        • ` + : `
        • ${text}
        • `; + }).join(""); + return `
            ${items}
          `; + }); // 8. Unordered lists html = html.replace(/^(?:[*-] .+(?:\n[*-] .+)*)/gm, (match) => { diff --git a/src/formats/native-renderer.test.ts b/src/formats/native-renderer.test.ts index 1790319..28ad0a8 100644 --- a/src/formats/native-renderer.test.ts +++ b/src/formats/native-renderer.test.ts @@ -78,4 +78,31 @@ describe("native renderer asset URLs", () => { expect(rewriteAppProtocolUrls(html, attachments)).toBe(html); }); + + it("resolves attachment paths relative to the rendered file in batch exports", () => { + // Regression (native A02): nested batch documents must reference the + // shared assets root from their own directory, not from the batch root. + const html = ''; + const attachments = [{ + sourcePath: "images/landscape.png", + outputRelativePath: "assets/landscape.png", + }]; + + expect(rewriteAppProtocolUrls(html, attachments, { + fromFile: "exports/folder/nested/part.html", + assetsRoot: "exports/folder", + })).toBe(''); + + // A document at the assets root itself keeps the plain shared path. + expect(rewriteAppProtocolUrls(html, attachments, { + fromFile: "exports/folder/index.html", + assetsRoot: "exports/folder", + })).toBe(''); + + // Deeper nesting adds one more parent hop. + expect(rewriteAppProtocolUrls(html, attachments, { + fromFile: "exports/folder/a/b/part.html", + assetsRoot: "exports/folder", + })).toBe(''); + }); }); diff --git a/src/formats/native-renderer.ts b/src/formats/native-renderer.ts index a93cb93..0c61149 100644 --- a/src/formats/native-renderer.ts +++ b/src/formats/native-renderer.ts @@ -102,9 +102,19 @@ function shouldIncludeRule(cssText: string): boolean { return true; } +/** + * Rewrite `app://` resource URLs to output attachment paths. + * + * Without `context` the returned path is the attachment's + * `outputRelativePath`, relative to the shared assets root — correct for + * single-file exports whose document sits at that root. Batch exports render + * documents at nested depths, so they pass `context` and the path is made + * relative to the rendered file's own location. + */ export function rewriteAppProtocolUrls( html: string, attachments: AttachmentCopy[], + context?: { fromFile: string; assetsRoot: string }, ): string { return html.replace(/\b(src|href|data)="(app:\/\/[^"]+)"/g, ( match, @@ -112,7 +122,9 @@ export function rewriteAppProtocolUrls( rawUrl: string, ) => { const outputPath = resolveAttachmentUrl(rawUrl, attachments); - return outputPath ? `${attribute}="${outputPath}"` : match; + if (!outputPath) return match; + if (!context) return `${attribute}="${outputPath}"`; + return `${attribute}="${relativePathBetween(context.fromFile, `${context.assetsRoot}/${outputPath}`)}"`; }); } From 4ffd6a3f740fe16283f673e2dadc94f36caeedca Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:55:46 +0800 Subject: [PATCH 5/5] docs: record defect fixes and native rerun, native artifacts gate to PASS D1/D2 fixed with regression tests (413/413 tests, lint and build clean). Affected native cases rerun in real Obsidian against build e371765a: A11 EPUB passes EPUBCheck 0/0/0 with the task list wrapped in a ul; A02/A03 HTML batches reference shared assets from each document's own directory and the nested page renders its image in Chrome. Vault restored and hash-verified after the run. Remaining native scope is the non-macOS platform rows. --- docs/releases/1.0.0/readiness.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/releases/1.0.0/readiness.md b/docs/releases/1.0.0/readiness.md index 267aa0a..d5ac5ef 100644 --- a/docs/releases/1.0.0/readiness.md +++ b/docs/releases/1.0.0/readiness.md @@ -2,7 +2,7 @@ Release decision: NOT READY Source commit: branch `acceptance/1.0-native-retry` (docs-only on top of merged main; executable bytes identical to main f0552fa plus description-string changes) -Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (26A428); Obsidian 1.13.7 (Electron 39.2.6, Chromium 142.0.7444.226); QA workspace: /var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD (temporary, retained until release verification); native run evidence: …/native/run3-20260918 (executed 2026-09-18) +Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (26A428); Obsidian 1.13.7 (Electron 39.2.6, Chromium 142.0.7444.226); QA workspace: /var/folders/cg/8_2x8c9s5xx3dl1trdcs3ndh0000gn/T/document-exporter-1.0-qa.UgHjVD (temporary, retained until release verification); native run evidence: …/native/run3-20260918 (executed 2026-09-18) and …/native/run4-20260918 (affected-case rerun after defect fixes) Native harness: committed at `scripts/native-acceptance/` (CDP-driven, trusted renderer input; see its README for the two required environment accommodations) | Gate | Status | Source commit / artifact SHA-256 | Evidence | Remaining action | @@ -10,10 +10,10 @@ Native harness: committed at `scripts/native-acceptance/` (CDP-driven, trusted r | Baseline | PASS | 2915687ebd656444054387fa474e0393bf420d7b | 2026-09-15 local run: `npm run check:version` ("Version metadata is consistent: 0.7.4"), `npm run lint:obsidian-warnings`, `npm run build`, `npm test` (20 files, 360 tests passed) — all exit 0. Remote read-only: 0 open issues, 0 open PRs; release 0.7.4 (published 2026-08-26) with `main.js`, `manifest.json`, `styles.css`; latest `verify` runs green including HEAD 2915687. No vault plugin symlink present, so builds cannot update a live plugin. | T0 complete | | Output integrity | PASS | merged main 207a4d3 (PR #84, commit b44207c) | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. Five export suites 145/145, full suite green, lint and build exit 0. | Native two-run case rerun executed in T6 (A04) — PASS | | Outcomes | PASS | e2c9dd3 (T3), 23e670a (T4) | `resolveExportStatus` state table (8 rows) plus 9 integration scenarios: early cancel, mid-batch cancel, cancel after render, second-source read failure (partial), PDF renderer rejection (failed), shared-attachment retry, missing input keeps original total, report-write failure stays completed-with-warning, unresolved-link-only stays completed. Runner finalizes through one path; `onFileComplete` reports completed count minus one. `exportResultMessage` distinguishes completed/partial/cancelled/failed for the UI. Full suite 411/411, lint and build exit 0. | Native dialog feedback wording verified in T6 (A08) — PASS | -| Headless artifacts | PASS | 3098745 | Fixture generator: 520 synthetic files, all manifest SHA-256 verified; second run refused non-empty destination; PNGs decode via macOS `sips` with matching dimensions (640x240, 160x100). Contract suite 10/10: Markdown/HTML-fallback/DOCX/EPUB content cases (markers, tables, code, XML validity, relationships, spine resolution, image byte equality, no `app://`), folder batch (nested primaries, relative links, shared attachment bytes), collision A→B, export-report name protection, missing-attachment and cancellation injections. `RELEASE_ARTIFACT_DIR` persistence verified with SHA-256 index (no PDF claims). Independent `unzip -t` passed on generated DOCX and EPUB. | Native artifact acceptance executed in T6; two defects found (see Native artifacts row) | -| Native artifacts | FAIL (executed; 2 defects) | candidate main.js SHA-256 530550d9fd20f520e4270f0020209f457f5ea9aad5716bdcb856a8f477cc4e2b, manifest f4d4268ae7a2b0aebd225e9831ac07ba4cf7ce7b8960de9354882ae11d97822b, styles f69c442f92e6a5ce3f4fc6caa43b65f1fef777c9f5dcb8ccf547174dc47bc264 (built from HEAD 856167f; installed in `/Users/Roger/my-vault` per T6.1, original restored after) | 2026-09-18 third attempt closed the automation gap: root cause of prior BLOCKED state was macOS Obsidian defaulting to NATIVE (Electron) menus when `nativeMenus` is unset — invisible to both AX and DOM automation. CDP harness (trusted renderer input) executed all A01–A12 in real Obsidian: A01 five formats (markers, 中文/emoji, byte-identical images); A02 folder batch ×5 formats (nested paths kept); A03 selected files (excluded part absent, documented "Unresolved link" warning shown); A04 A→B→A (first-run bytes stable; reruns redirect to `-` siblings; red/blue image resolution correct); A05 explicit overwrite + vault source untouched; A06 report sentinel preserved + missing-reference diagnostics; A07 17-export expand/copy matrix (WANTED-SENTINEL only when expanded, EXCLUDED never, bounded cycle, copy=false → no assets but PDF keeps embedded image); A08 PDF mid-batch cancel 207/501 with accurate feedback and kept outputs (markdown batches finish before cancel lands — twice observed, recorded); A09 long PDF 22 pages with first/middle/last sentinels + DOCX complete; A10 loopback probe: preview 0, HTML export 3, PDF render 2, browser-open 2 requests — the export pipeline does fetch remote image URLs referenced in notes; A11 degradation recorded; A12 html-100 23s / md-501 1.9s / html-501 114s with "Large export: 501 files" warnings on both 501 runs. T6.3 independent tools: EPUBCheck 5.2.1 `--failonwarnings` A01/A02 clean (0 fatal/0 error/0 warning); LibreOffice 26.8 opens all five DOCX (no repair dialog); `unzip -t` clean; PDFKit page/sentinel/page-render checks pass; Chrome headless renders pass. DEFECT D1 (invalid EPUB): fallback renderer emits bare `
        • ` without `
            ` (`src/formats/html-document.ts:138`), so any note with a task list exports an EPUB that fails EPUBCheck RSC-005 (limitations.epub, OEBPS/chapter-1.xhtml:12). DEFECT D2 (broken nested images in HTML batches): attachment `src` written relative to the batch root (`nested/part.html` → `assets/landscape.png`, missing from `nested/`); the Markdown bundle writes the correct `../assets/` form, so the HTML path misses the per-note depth adjustment. Also recorded (not a defect): native-render batches crawl when the Obsidian window is backgrounded (Chromium timer throttling); rerun used the documented anti-throttling launch flags — foreground users unaffected. Prior-session cleanup corrected: the vault had still contained the run-2 candidate install and a typo'd workspace folder (`elease-acceptance-1.0-cu-20260917`); both removed, original plugin (main.js bc226ff2…) and `nativeMenus` config restored and hash-verified. | Fix D1 and D2 with regression tests (headless suites currently pass — add task-list EPUB and nested HTML-batch cases), rebuild, rerun affected native cases (A11 EPUB, A02/A03 HTML batch) plus the full test/lint/build gates | -| Platforms | BLOCKED (macOS row executed) | macOS row: same candidate build as above | macOS + current stable Obsidian row executed 2026-09-18 (A01–A12 plus clean candidate install/enable/reload with version verified in-app). Missing — Windows, Linux, iOS, Android environments and an isolated desktop Obsidian 1.4.0 install; none tested, no platform claim made for them. Harness is reusable on desktop platforms. | Execute the remaining bounded matrix rows on the required devices after D1/D2 fixes | -| Documentation | IN PROGRESS | — | T7 executed evidence-scoped: descriptions/setting text are code-backed; capability table cites headless-verified rows and marks native verification pending; privacy paragraph unchanged pending A10. A10 evidence now exists (see Native artifacts row): wording must not claim "no network requests" — observed behavior is that rendering/export fetches remote image URLs referenced in notes (loopback probe: preview 0, export 3, PDF 2, opened-HTML 2). | Finalize capability table native columns (excluding the two defect areas until fixed) and privacy wording from A10 observations | +| Headless artifacts | PASS | 3098745 | Fixture generator: 520 synthetic files, all manifest SHA-256 verified; second run refused non-empty destination; PNGs decode via macOS `sips` with matching dimensions (640x240, 160x100). Contract suite 10/10: Markdown/HTML-fallback/DOCX/EPUB content cases (markers, tables, code, XML validity, relationships, spine resolution, image byte equality, no `app://`), folder batch (nested primaries, relative links, shared attachment bytes), collision A→B, export-report name protection, missing-attachment and cancellation injections. `RELEASE_ARTIFACT_DIR` persistence verified with SHA-256 index (no PDF claims). Independent `unzip -t` passed on generated DOCX and EPUB. | Native artifact acceptance executed in T6; the two defects it surfaced are fixed on this branch with regression tests added to this suite | +| Native artifacts | PASS (after defect fixes) | candidate main.js SHA-256 e371765a88e98f20af0e423f401367804d1ff3e315eefd37baacdceb14b3b18b, manifest f4d4268ae7a2b0aebd225e9831ac07ba4cf7ce7b8960de9354882ae11d97822b, styles f69c442f92e6a5ce3f4fc6caa43b65f1fef777c9f5dcb8ccf547174dc47bc264 (built from the fix commit on `acceptance/1.0-native-retry`; installed in `/Users/Roger/my-vault` per T6.1 for each run, original restored after) | 2026-09-18 third attempt closed the automation gap: root cause of prior BLOCKED state was macOS Obsidian defaulting to NATIVE (Electron) menus when `nativeMenus` is unset — invisible to both AX and DOM automation. CDP harness (trusted renderer input) executed all A01–A12 in real Obsidian against build 530550d9…: A01 five formats (markers, 中文/emoji, byte-identical images); A02 folder batch ×5 formats (nested paths kept); A03 selected files (excluded part absent, documented "Unresolved link" warning shown); A04 A→B→A (first-run bytes stable; reruns redirect to `-` siblings; red/blue image resolution correct); A05 explicit overwrite + vault source untouched; A06 report sentinel preserved + missing-reference diagnostics; A07 17-export expand/copy matrix (WANTED-SENTINEL only when expanded, EXCLUDED never, bounded cycle, copy=false → no assets but PDF keeps embedded image); A08 PDF mid-batch cancel 207/501 with accurate feedback and kept outputs (markdown batches finish before cancel lands — twice observed, recorded); A09 long PDF 22 pages with first/middle/last sentinels + DOCX complete; A10 loopback probe: preview 0, HTML export 3, PDF render 2, browser-open 2 requests — the export pipeline does fetch remote image URLs referenced in notes; A11 degradation recorded; A12 html-100 23s / md-501 1.9s / html-501 114s with "Large export: 501 files" warnings on both 501 runs. T6.3 independent tools: EPUBCheck 5.2.1 `--failonwarnings`; LibreOffice 26.8 opens all sampled DOCX (no repair dialog); `unzip -t` clean; PDFKit page/sentinel/page-render checks; Chrome headless renders. Two defects were found and FIXED on this branch: D1 — fallback converter emitted a bare `
          • ` (no `
              `), making any task-list note's EPUB invalid (EPUBCheck RSC-005; `src/formats/html-document.ts`); D2 — HTML batch exports wrote nested documents' attachment `src` relative to the batch root, breaking images in nested pages (`rewriteAppProtocolUrls` lacked the output-file context; the Markdown bundle was already correct). Regression tests added: EPUB chapter content-model check (every `li` inside a list, ☐/☑ glyphs), fallback HTML task-list structure, and per-file asset resolution at three nesting depths; full suite 413/413, lint and build exit 0. Affected native cases rerun in real Obsidian against the fixed build e371765a… (run4-20260918): A11 EPUB passes EPUBCheck `--failonwarnings` 0/0/0 with `
              • ☐ Task item
              ` in the chapter; A02/A03 HTML batches write `../assets/landscape.png` from nested pages and `assets/landscape.png` from root pages, and the nested page renders its image in Chrome (screenshot retained). Unaffected A-cases do not exercise the changed code paths (task-list conversion lives in the fallback renderer/EPUB chapters; the URL rewrite context only affects native HTML rendering), so their run3 evidence stands. Also recorded (not a defect): native-render batches crawl when the Obsidian window is backgrounded (Chromium timer throttling); rerun used the documented anti-throttling launch flags — foreground users unaffected. Prior-session cleanup corrected: the vault had still contained the run-2 candidate install and a typo'd workspace folder (`elease-acceptance-1.0-cu-20260917`); both removed, original plugin (main.js bc226ff2…) and `nativeMenus` config restored and hash-verified after every run. | None on macOS — remaining native scope is the non-macOS platform rows | +| Platforms | BLOCKED (macOS row executed) | macOS row: builds 530550d9… (full matrix) and e371765a… (affected-case rerun) | macOS + current stable Obsidian row executed 2026-09-18 (A01–A12 plus clean candidate install/enable/reload with version verified in-app; affected cases rerun after the defect fixes). Missing — Windows, Linux, iOS, Android environments and an isolated desktop Obsidian 1.4.0 install; none tested, no platform claim made for them. Harness is reusable on desktop platforms. | Execute the remaining bounded matrix rows on the required devices | +| Documentation | IN PROGRESS | — | T7 executed evidence-scoped: descriptions/setting text are code-backed; capability table cites headless-verified rows and marks native verification pending; privacy paragraph unchanged pending A10. A10 evidence now exists (see Native artifacts row): wording must not claim "no network requests" — observed behavior is that rendering/export fetches remote image URLs referenced in notes (loopback probe: preview 0, export 3, PDF 2, opened-HTML 2). | Finalize capability table native columns (now including the fixed task-list EPUB and nested HTML batch rows) and privacy wording from A10 observations | | Release gate | PASS (local) | 064f518 | `scripts/check-version.mjs` extended to lockfile root and `versions.json`↔`minAppVersion` mapping; `node --test scripts/check-version.test.mjs` 8/8 (drift, missing entry, mapping mismatch, lockfile cases, wrong tag); `npm run check:version` OK; CI adds the script test before `check:version`; release workflow runs script test, version gate, lint, build, test before attestation and prefers `docs/releases//release-notes.md`; both workflow files parse as valid YAML; CLAUDE.md release guidance updated. | None — CI verified the workflow changes on PR #85 (verify pass) | -| Upgrade / candidate | BLOCKED | Unmeasured | T9 not executed: the bump prerequisite (T1–T8 complete, native artifact/platform rows PASS) is not met — Native artifacts is FAIL (D1/D2) and non-macOS platform rows are unmeasured. | Fix D1/D2, rerun affected native evidence, close platform rows, then execute T9 (bump, final gates, install/upgrade smoke) | -| Published assets | BLOCKED | Unmeasured | Not published. T10 requires all required gates PASS and publication authorization; Native artifacts is FAIL and Platforms is BLOCKED, so publication is refused by the plan's own rule ("Never publish with a required gate marked BLOCKED or FAIL"). | Resolve native defects and platform rows (or an explicit, documented product-decision scope change by the owner), then authorize T10 | +| Upgrade / candidate | BLOCKED | Unmeasured | T9 not executed: the bump prerequisite (T1–T8 complete, native artifact/platform rows PASS) is not met — native artifacts is now PASS on macOS, but the non-macOS platform rows are unmeasured. | Close the remaining platform rows, then execute T9 (bump, final gates, install/upgrade smoke) | +| Published assets | BLOCKED | Unmeasured | Not published. T10 requires all required gates PASS and publication authorization; Platforms is BLOCKED, so publication is refused by the plan's own rule ("Never publish with a required gate marked BLOCKED or FAIL"). | Close platform rows (or an explicit, documented product-decision scope change by the owner), then authorize T10 |