diff --git a/docs/releases/1.0.0/readiness.md b/docs/releases/1.0.0/readiness.md index c0a8dfd..d5ac5ef 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) 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 | |---|---|---|---|---| | 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. | 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 | -| 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; 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 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 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 | 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)); 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}`)}"`; }); }