Compare commits

...

60 commits
1.0.7 ... main

Author SHA1 Message Date
wujunchen
6519633459 chore: bump version to 1.0.24
Change-Id: I6ada16f42f4f76781e25783493fbf6da88a640c1
2026-06-16 10:08:56 +08:00
wujunchen
2278129f03 Merge branch 'feat/robustness-onboarding-ci'
Provider/lifecycle robustness, first-run onboarding nudge, CI coverage gate, and
codex-review fixes. All gates green (typecheck, lint, obsidian strict review, 28
test files, branch coverage 100%, e2e gate).

Change-Id: Id47ba4d2bb8b6c847eb7563459d416ddfa1935f0
2026-06-16 10:08:09 +08:00
wujunchen
7986f1c308 fix: address codex review — drop false-positive fallback keywords, tighten stream-error detection
Two valid findings from an opposite-model (Codex) review of this branch:

- provider-request: drop bare `unknown`/`unrecognized` from the structured-output
  rejection keywords. They false-positive on model-name errors ("unknown model",
  "unrecognized model ID" from Ollama/Bedrock), triggering a wasted fallback
  retry. The specific feature tokens + bare `schema` still cover real structured-
  output rejections, so true-positive detection is unaffected.
- streaming: streamErrorMessage's OpenAI-style `{error:{...}}` branch now only
  treats the payload as an error when it carries a message/type, so a stray empty
  `error: {}` (or code-only object) in a normal chunk no longer aborts the stream.
  The Anthropic `{type:'error'}` branch still always signals an error.

Declined: Codex's suggestion to convert streaming's HTTP>=400 to ProviderApiError
+ add fallback — the streaming path always sends `structured:false`, so there is
no structured request to fall back from.

Tests updated/added: model-name 400s do not retry; empty/code-only error objects
do not throw.

Change-Id: I413d633c8ce7036365f97e2d8e1287c111e0902f
2026-06-16 10:05:04 +08:00
wujunchen
5f36b1218b feat: provider/lifecycle robustness, onboarding nudge, CI coverage gate
Assessment-driven batch of 10 verified, low-risk improvements. All gates green
(typecheck, biome, obsidian lint + strict review, 28 test files, branch
coverage 100%, e2e gate).

Correctness:
- provider-request: decide structured-output fallback on HTTP status via a new
  ProviderApiError (status+body) instead of pattern-matching the i18n-translated
  message — previously only en/zh matched, so fr/de/es/ja/ko users hit silent
  permanent failures when a provider rejected json_schema.
- streaming: surface in-stream provider error payloads ({type:'error'} /
  {error:{}}) by throwing, instead of swallowing them and later misreporting a
  transient overload/quota error as "non-JSON LLM output". Note: detection runs
  outside the JSON.parse try/catch so the throw is not swallowed.
- main: throwIfCancelled before cacheManager.put so a cancelled job cannot
  poison the cache.
- generation-job-manager: add cancelAll(); onunload now cancels in-flight jobs
  (aborting streaming HTTP + SIGKILL-ing CLI children), not just queued waiters.

Onboarding / UX:
- settings: DEFAULT_SETTINGS.promptLanguage 'zh' -> 'auto' so new non-Chinese
  users get source-language summaries by default (existing users unaffected).
- view/main/types: first-run "Set up AI provider" CTA in the empty state when no
  credential is configured (PluginHost.openSettings + isCredentialConfigured).
- error-ui/generation-job-manager/types: new 'network' ErrorKind with an
  actionable notice + Retry for offline/connection failures.
- i18n-strings: 4 new keys across all 7 locales (parity test enforced).

CI / docs:
- .c8rc.json + package.json: branch-coverage gate (check-coverage, branches=100,
  degenerate metrics disabled) and preserve c8's exit code in the coverage script.
- ci.yml: run coverage gate and strict obsidian review in CI.
- README: fix Obsidian version badge 1.4.0 -> 1.8.7 (matches manifest).
- e2e product-shell DOM shim: add createSpan (was missing; real Obsidian has it).

Tests: cover the status-based fallback (incl. non-English locale), in-stream
error throwing, cancelAll, and network classification.

Change-Id: Ic619098aa7cdf3dc1c444be4bb8a445550eadf55
2026-06-16 09:37:59 +08:00
wujunchen
c45f7987e3 fix: avoid stringifying locale objects
Change-Id: Ic9557acc384d5a23b3cbaaae9fea2678e9afd661
2026-05-20 13:23:56 +08:00
wujunchen
2faf1729f2 Expand i18n language support
Change-Id: I31bf34852ea46947dab186a46942839db7eaaaca
2026-05-19 13:12:11 +08:00
wujunchen
68bfed7f56 perf: eliminate per-card document rescans and redundant hashing
- anchor.ts: precompute line-offset index once; O(log n) binary-search
  offsetToLine replaces two O(n) newline-count loops. findLineForAnchor
  takes an optional lineOffsets param (backward compatible).
- cards.ts / generation.ts: build the line-offset index once per document
  and thread it through all card anchor resolutions (was N cards x ~9
  full-document O(n) passes -> 1 O(n) pass + N x O(log n)).
- anchor.ts: ASCII-whitespace charCode fast path in normalizeWithMap,
  deferring to /\s/ only for non-ASCII (exact semantics preserved).
- settings.ts: memoize generationFingerprint by settings object identity
  (WeakMap) — collapses SHA-1 + stableStringify done on every file-open.
- view.ts: remove keydown listener in onClose (lifecycle leak fix).

Verified: tsc, build, biome, all 28 tests, e2e smoke, branch coverage
100% (new anchor regression tests added). Deferred (higher risk /
test-breaking): view full-render->surgical DOM, scroll rebind guard,
cache scheduleSave timing — see notes/perf-report.md.

Change-Id: I17eef347471bb8233d48164e4453d492f301eac2
2026-05-16 00:33:51 +08:00
wujunchen
052aa13547 test: cover remaining branch gaps in cache.ts and markdown.ts
Branch coverage 27/27 -> 32/32 (100%):
- touchCacheEntry(null) and default-now path
- serializeCacheFile(falsy) -> {}
- shouldConfirmRegenerate all guard branches
- cardToPlain with undefined bullets

Change-Id: I4971f8808727c63f303ebd8c23ca599673928e84
2026-05-16 00:24:33 +08:00
wujunchen
606593e39b test: add c8 coverage harness with source-map remapping
- npm run coverage runs the full suite under c8
- Wire inline source maps into the esbuild test bundles
- Redirect bundles into repo (.test-bundles) under coverage so c8
  processes them; cleaned automatically after the run
- Fix esbuild->v8-to-istanbul source path remap (tests/coverage-sourcemap.js)
- .c8rc.json, .gitignore, dev-dep c8

Note: statement/function totals are degenerate under esbuild bundling;
branch coverage + HTML report are the reliable signals.

Change-Id: I880f5ee1e9a8e5368e27a8e274825f917d972269
2026-05-16 00:22:31 +08:00
wujunchen
de66de5b5c refactor: remove dead code and over-broad exports
- Delete unused CacheFile interface (src/types.ts)
- Demote internal-only symbols from export to module scope:
  PROMPT_VERSION, DEFAULT_CLI_TIMEOUT_MS, MIN_STREAMING_TIMEOUT_MS,
  MIN_CLI_TIMEOUT_MS (settings.ts), CliFailureReason (cli.ts),
  GenerationErrorContext (error-ui.ts), AnthropicMessagesBody/
  OpenAiChatBody/OpenAiResponsesBody/GeminiBody (provider-bodies.ts),
  BackendTestDeps (backend-test.ts)

Verified: tsc, esbuild prod build, biome, all 28 test files pass.
Change-Id: I0fc597ee51ec67d509a7f33db39fabcbdb301797
2026-05-16 00:15:10 +08:00
wujunchen
955a86e2c5 chore: bump version to 1.0.22
Change-Id: Ic89af734cc2d560485f1a2b116ac763b7765c927
2026-05-14 10:22:16 +08:00
wujunchen
9473ff8434 style: apply biome formatter to cli.test.js
Change-Id: I52932009843be1d12872066bf462e01f119ab028
2026-05-14 10:19:46 +08:00
wujunchen
5a5baf0910 chore: bump version to 1.0.21
Change-Id: I1e7f7b914e407009da954ce5b7e5a75f9e90906b
2026-05-14 10:15:53 +08:00
wujunchen
617cc9b668 fix: pass --verbose with stream-json for Claude CLI 2.1.131+ (#3)
Claude CLI 2.1.131+ rejects `--output-format stream-json` unless `--verbose`
is also passed. Without it the CLI exits 1 with "When using --print,
--output-format=stream-json requires --verbose", breaking the Claude Code
backend.

Adds the flag in src/cli.ts and locks both the flag and full args contract
for Claude + Codex backends with new tests:
- testClaudeCodeArgs: extended with mcp-config / disallowed-tools / chrome
  / slash-commands assertions and --verbose position check
- testClaudeCodeStreamJsonResilience: banner-mixed NDJSON, missing result,
  empty stdout, multiple result events, content fallback
- testClaudeCodeVerboseRegressionCanary: simulates real CLI stderr on
  missing --verbose and asserts error pipeline preserves exitCode + stderr
- testCodexErrorPath: codex non-zero exit propagates structured error
- testCodexArgsMinimalContract: deepStrictEqual lock on codex args 5-tuple
- testClaudeBackendSurfacesVerboseError: smoke flow surfaces the canary
- testCodexBackendPropagatesVersionFailure: version probe failure blocks
  smoke instead of silently continuing

Also wires npm run lint:obsidian into CI alongside Biome.

Closes #3

Change-Id: I6dbaa1b55172eb0b5b773bd25d1741b0603a4e4e
2026-05-14 10:15:50 +08:00
wujunchen
50c76f528e fix: clear Obsidian review unused bindings
Change-Id: I0f41318fd4bd723de818213941dda9de3c0f33fd
2026-05-09 13:38:29 +08:00
wujunchen
714b6a368b fix: harden CLI backend smoke checks
Change-Id: I7b4388095412b155c056c42661cdf481bd601e4c
2026-05-09 11:49:23 +08:00
wujunchen
d00075779e ci: make release upload idempotent
Change-Id: I826fcc302c0ea20877c66045e1e264efa7dbb3d5
2026-05-09 11:16:59 +08:00
wujunchen
04428950de chore: prepare 1.0.19 release
Change-Id: I86c01c3d6a2c13a193636ac08ba562d54a2e9a20
2026-05-09 10:48:36 +08:00
wujunchen
07faeddab5 fix Obsidian plugin review scan
Change-Id: I43416145eca8ee04468522c350fdcebeb772ab6e
2026-05-08 22:57:31 +08:00
fancivez
a57743e7e2 chore: bump version to 1.0.17
Change-Id: I4c5eaa3d25ed804ad95d8d53b100ed0186eee79d
2026-05-05 21:29:38 +08:00
fancivez
d738fc120a feat(error-ui): structured CliProcessError + ErrorKind-driven UI dispatch
Replaces the single long-Notice failure UX with a kind-aware dispatcher
so users get actionable buttons instead of a wall of text.

- CliProcessError extends Error with a typed CliErrorDetails payload
  (reason / pid / elapsed / idle / bytes / tails / exitCode / signal /
  timeoutMs / idleTimeoutMs). All five runCli failure paths now throw
  the typed error; message format is preserved so existing classifier
  regex and tests still match.
- classifyGenerationError short-circuits on details.reason for the
  deterministic cases (wall/idle-timeout → timeout, spawn/startup →
  config, streams-unavailable → unknown). exit-nonzero falls through
  so stderr-derived auth/rate-limit hints still classify correctly.
  Duck-typed check avoids cli ↔ generation-job-manager circular import.
- New error-ui.ts dispatches by ErrorKind:
    timeout (with details) → TimeoutDiagnosticsModal showing cmd, pid,
      elapsed, idle, bytes, redacted stderr/stdout tail, plus copy /
      open-settings / close buttons.
    timeout (no details, e.g. API streaming) → actionable Notice.
    auth/config → Notice + "Open Settings" CTA.
    rate-limit → Notice + "Copy details".
    schema → Notice + "Copy raw output" CTA.
    unknown → unchanged legacy short Notice.
- main.ts handleGenerationError delegates to dispatcher; new
  openPluginSettings() helper uses Obsidian's app.setting.openTabById.
- 21 i18n strings (zh + en) for all new UI labels and the modal copy.
- styles.css adds error notice + diagnostics modal styling.
- 7 new test assertions cover typed details on every failure path and
  classifyGenerationError respecting structured reasons.

Change-Id: I8af46d375d92ba26d14180ffbeb6c3a6dedd89f5
2026-05-05 20:19:57 +08:00
fancivez
89464608aa feat(cli): enrich CLI timeout diagnostics + idle timeout + debug logging
The CLI provider previously surfaced only "CLI timed out (Xms)" on hang,
discarding accumulated stderr/stdout, pid, and timing — leaving users
unable to tell network hangs from auth failures from a slow model.

- runCli timeout/idle errors now include pid, elapsed, idle duration,
  utf-8 byte counts, plus stderr/stdout tails (with secret redaction
  for Bearer tokens, sk-* keys, and Authorization-style headers).
- Add optional cliIdleTimeoutMs (default 0/disabled): kill the CLI if
  no output for N ms, independent of the wall-clock cliTimeoutMs.
- Add optional debugLogging: console hooks at spawn/ok/fail with pid
  and byte counts for live diagnosis.
- Settled-guard on noteActivity/armIdleTimer prevents stale timers
  after first-wins settle (cancel / wall / idle / child-error / close).
- New unit coverage: rich diagnostic suffix, idle timeout firing, idle
  reset on heartbeat, secret redaction, idle normalization edge cases.

Change-Id: I9191f8d348ef43ff9605dc5169f0951f5af85d26
2026-05-05 17:33:25 +08:00
wujunchen
65cb81730a chore: bump version to 1.0.16
Change-Id: Ic4e1e043af1256b8a9de7d0dac47160643724649
2026-05-03 10:46:43 +08:00
wujunchen
7d5bfffecf chore: bump minAppVersion to 1.7.2 to match revealLeaf API requirement
Workspace.revealLeaf was added in Obsidian 1.7.2 but the plugin has
been declaring minAppVersion 1.4.0 since 1.0.0. Update the manifest
and pin 1.0.15 in versions.json so the install gate reflects reality.
Older release entries in versions.json remain at 1.4.0 to avoid
retroactively changing their published compat declaration.

Change-Id: I1fa8c02770b4e88f9f1e1b31835adf4d4af5d718
2026-05-03 10:45:16 +08:00
wujunchen
33371cadd6 chore: integrate obsidianmd eslint plugin and fix recommended rules
Add eslint v9 + @typescript-eslint/parser + eslint-plugin-obsidianmd as
a dedicated `npm run lint:obsidian` track, separate from the existing
biome lint. Apply autofixes from the recommended config and resolve the
follow-on type/test issues.

- prefer-create-el: createEl('div'/'span') -> createDiv/createSpan
- prefer-active-window-timers: setTimeout/clearTimeout -> activeWindow.*
  + retype timer holders from ReturnType<typeof setTimeout> to number
- prefer-active-doc: drop globalThis.fetch in streaming
- no-useless-catch: drop no-op rethrow wrapper
- no-restricted-globals: streaming.ts is exempted because requestUrl
  does not support streaming responses
- @typescript-eslint/no-unsafe-*: disabled (out of scope for an Obsidian
  plugin lint pass; tsc + biome already cover type safety)
- tests: polyfill globalThis.activeWindow so unit tests still run in Node

Change-Id: I43cc652e29a66d490e2834e940843c39b211b80b
2026-05-03 10:45:10 +08:00
fancivez
83dc9b2874 chore: bump version to 1.0.15
Change-Id: Idcbf67cc7a2ee535a8d06719b8205ea4b1aa3931
2026-05-02 15:54:43 +08:00
fancivez
e4b2ae3e22 refactor(settings): collapsible sections + smart auto-expand
The settings tab listed ~23 entries on first paint (API mode), most of
which the typical user never touches. Restructured into 5 sections,
of which only the first two are expanded by default.

Sections (default visibility):
- Quick setup (open):     backend / provider preset / credential / model / test
- Reading output (open):  prompt language / card range / export folder / UI lang
- Advanced connection:    api format / base URL / auth type / headers /
                          max tokens / streaming + timeout merged
- Advanced prompt:        max input chars / custom system prompt
- Cache & maintenance:    max entries / clear-all

First-paint count drops from ~23 to ~9 rows.

Smart auto-expand: Advanced connection opens automatically when the
user has departed from preset defaults — custom-* provider, custom
headers, non-default base URL, non-auto auth type, streaming=false,
non-default max tokens, or api format mismatching preset. Same idea
for Advanced prompt (custom prompt or non-default max input chars)
and CLI advanced (non-default cli timeout). Old users find their
configured values without manual digging.

Other simplifications:
- API key + env var fallback combined into one row. The env var input
  lives behind a tiny details/summary disclosure and is built with
  raw DOM (label + input) instead of a nested `new Setting` to avoid
  Obsidian theme CSS conflicts on .setting-item.
- Provider preset row gains an inline read-only summary of the
  derived API format and base URL, replacing the standalone rows
  for those two fields under built-in presets.
- Streaming toggle and streaming timeout are now one row; the
  timeout input is wrapped in its own div and toggled visible only
  when streaming is on.

No data.json changes — every settings key keeps its existing name and
default value. Existing installs upgrade with zero migration.

styles.css: add scoped classes for the collapsible sections, env-var
inline row, timeout inline input, and a generic .parallel-reader-hidden.
i18n: 7 new keys × zh+en for section headings + summary fallbacks.

Codex review pass:
- shouldOpenAdvancedConnection now also reacts to streaming=false /
  non-default apiMaxTokens / apiFormat ≠ preset.format.
- streaming + timeout merged row hides a dedicated wrapper instead of
  inputEl.parentElement (which on some themes shares the controlEl
  with the toggle).
- env-var fallback no longer nests `new Setting` inside a controlEl.
- "(no base url)" placeholder in the preset summary is now i18n'd.

Change-Id: I8ef11c56a89121df13a630b1c724652e78b4069f
2026-05-02 15:54:43 +08:00
fancivez
97db81b5c8 chore: bump version to 1.0.14
Change-Id: Id0f8fc86c95ec2ab5ad841dcfe60c1af71b1407b
2026-05-02 15:38:41 +08:00
fancivez
a5d9d74b54 feat: toggle right sidebar instead of detaching tab + component tests
UX change:
- The "open Parallel Reader" command now toggles the right sidebar's
  collapsed state instead of detaching the leaf. The tab and its content
  are preserved across hide/show, matching standard Obsidian behaviour
  for File Explorer and friends. Detaching the tab on every second
  press destroyed cached cards and forced re-generation.

Behaviour:
- No leaf yet → create + reveal
- Leaf exists, right sidebar collapsed → reveal (expands sidebar)
- Leaf exists, right sidebar expanded → collapse(); tab is preserved
- No right sidebar (mobile / unusual layout) → reveal as fallback

Tests:
- New tests/view-render.test.js (18 cases) covering the component layer
  that was previously a gap. The 1.0.12 → 1.0.13 sidebar regression
  could not have been caught by any existing pure-function test; this
  file fills that gap by mocking app.workspace + view spies and running
  real runForFile / refreshViewAfterCache* / toggleParallelView.
- shouldRender matrix (4 cases): silentView × sourceFile state — the
  exact regression introduced in PR2 where guards were over-applied.
- toggleParallelView (4 cases): no-leaf opens, expanded → collapse,
  collapsed → reveal, no-rightSplit fallback.
- refreshViewAfterCacheDelete + Clear (4 cases): matching file vs
  different file vs no-view, plus clear-all always renderEmpty.
- runForFile outcome enum mapping (6 cases): already-running early
  return, already-running from catch, cancelled, generic error,
  regenerate-confirm cancellation, skipEditConfirm bypass — these
  drive batch statistics and were untested before.
- view.deleteCard / updateCard cardPersistFailed path (3 cases):
  exposes ParallelReaderView via test-exports for instantiation;
  mocks cacheReplaceCards returning false to verify the failure
  notice path lands and the public method returns false.

src/test-exports.ts: export ParallelReaderView for component tests.
tests/catalog.json: register view-render.test.js under component.

Change-Id: I439b5fca07b0928e6fe44d9025c2421c71c6cb28
2026-05-02 15:38:41 +08:00
fancivez
8e055cce50 chore: bump version to 1.0.13
Change-Id: I56e56f0f6fd3a440d26249b5baf845c65db15785
2026-05-02 14:10:49 +08:00
fancivez
d058348f6f fix: regressions in 1.0.12 — sidebar render guard + open-view toggle
Two regressions reported after 1.0.12 was installed:

1. Hotkey-triggered Generate did not update the sidebar. PR2 added
   `viewIsShowingFile(view, file)` guards to renderLoading/loadFor/
   renderError in runForFile to keep batch (silentView) jobs from
   disturbing a panel showing a different note. The guard was applied
   to all paths, breaking the interactive case: when the sidebar had
   never been opened (sourceFile=null) or was showing a different note,
   every render call was skipped and the sidebar stayed blank while
   generation completed in the background.

   Fix: compute `shouldRender = !silentView || viewIsShowingFile(...)`
   once. Non-silent (user pressed Generate on this file) renders
   unconditionally; silentView keeps the existing "only if it's already
   this file" behaviour.

2. The open-view command had no toggle behaviour. Pressing the hotkey a
   second time silently re-opened the same panel.

   Fix: if a parallel-reader leaf already exists, detach it; otherwise
   ensureView + sync.

Change-Id: I1db3d70806d8d07965d18bf3717023de027ac577
2026-05-02 14:10:49 +08:00
fancivez
a283cb588a chore: bump version to 1.0.12
Change-Id: I25e04737ba455e13c9809f517444df430c582c56
2026-05-02 13:52:53 +08:00
fancivez
76d2e8050a fix: address 10 P2 behavioral and concurrency findings
Findings from the original review (P2):
- streaming: flush unterminated final SSE event at EOF (some providers
  close the stream without a trailing blank line, dropping the last delta)
- cache-manager: validate entry shape on load — drop entries where
  cards is not an array, anchor is not a string, or bullets is not an
  array. Tolerates missing optional fields (treated as cache miss).
- view: card edit/delete now check cacheReplaceCards return and surface
  failures via a localized Notice instead of pretending success
- main: clear-current / clear-all / file-menu-clear refresh open view
  via renderEmpty so stale UI does not display deleted data
- prompt + settings-tab: card count is normalized via a single helper
  used by buildPrompts and onChange. Prompt and fingerprint stay in sync.
  UI value is written back to the textbox after clamping.
- generation-job-manager: global concurrency limit (default 3) with a
  cancellable wait queue. Race-safe slot accounting via a reserved
  counter so resolved-but-not-yet-set waiters are visible to fast-path
  start() callers.
- runForFile: now returns RunForFileResult; accepts preloadedContent +
  silentView + skipEditConfirm options used by batch (and reflected on
  the PluginHost interface).
- batch: avoid double file read, do not steal UI focus, classify
  results correctly (generated / cached / already-running / empty / ...)

Follow-up from the 1.0.11 review:
- generationFingerprint: codex backend excludes settings.model from the
  hash. Codex ignores --model; previously editing model spuriously
  invalidated all codex cache.

Codex review pass:
- generation-job-manager: race fix — releaseSlot's resolve microtask
  and a synchronous start() fast-path could briefly overshoot
  maxConcurrent. Reserve the slot synchronously inside the wrapped
  resolve to close the window.
- cache-manager: anchor type validation in addition to bullets.

NOTE: Codex backend users will see a one-time "stale cache" banner on
existing notes due to the fingerprint change; regenerate to refresh.

Change-Id: I7721c7dfe51dea3f51b0215764f721523c2f6806
2026-05-02 13:52:49 +08:00
fancivez
d1f36d52a0 chore: bump version to 1.0.11
Change-Id: I1a24c7e05011ff320eccfd5e917ec3793fa3164e
2026-05-02 13:11:27 +08:00
fancivez
c10c641949 docs: trim e2e gate section in readme
Drop the self-contained/no-Python historical note and the contributor-
focused tests/catalog.json paragraph; keep the gate command + TEST_LIVE=1
hint. Public README does not need internal test classification details.

Change-Id: Ic7a2ed45af0ebeb3be227aed03079ce258ed30c2
2026-05-02 12:51:19 +08:00
fancivez
01ec934d28 fix: address 13 review findings (security, UX, i18n)
P1:
- cli: pin codex --sandbox read-only (defeat prompt injection)
- settings: clear apiKey/apiHeaders on provider preset switch
- main/view: render error state when LLM returns empty cards
- schema/cli: do not log raw model output; UI errors expose length only

P2:
- cli: pass --model to Claude Code when settings.model set
- settings-tab: add CLI timeout input + normalizeCliTimeoutMs (min 1s)
- view: export source link uses [[path|basename]]
- view: export failures surface a localized Notice

P3:
- schema: cardUntitled fallback now respects uiLanguage
- generation-job-manager: error codes localized via main.ts
- batch: settled guard on promptForBatchFolder modal
- README: drop stale e2e validator install note

Codex review pass:
- provider-parsers: forward settings to normalizeCardsPayload
- generation-job-manager: classifyGenerationError regex covers new EN/zh
  schema-error messages
- cli: do NOT pass --model to codex (DEFAULT_SETTINGS.model is a Claude
  name and would break codex; codex relies on its own config)

Tests: cli (codex sandbox + claude --model + classify zh/en), settings
(applyApiProviderPreset isolation + normalizeCliTimeoutMs), schema
(language-aware fallback title), test-exports (new symbols).

Change-Id: I9f4c21f9299e1a9bf166f69f712859854482fe92
2026-05-02 12:50:35 +08:00
fancivez
dc03345dc7 chore: bump version to 1.0.10
Change-Id: I2ba1c339f13e4e40fcaa5c85e911b51b97f19640
2026-04-30 15:02:06 +08:00
fancivez
418f733d14 ci(e2e): enforce self-contained release gate
Change-Id: I4a7385a861981223edf5b4a3f1861b349ef520bf
2026-04-30 14:45:56 +08:00
fancivez
15e81cbcb6 docs: expand readme badges and language switcher
Change-Id: Ia547e146cca71f0daf04c896fd14f892940ed707
2026-04-29 18:16:43 +08:00
wujunchen
310c81a073 fix: add cancel action to batch prompt
Change-Id: Ic3922d55d776d369959eff4e5c811c5d3434db2b
2026-04-29 14:13:47 +08:00
wujunchen
57c1c0e124 docs: document e2e contract gate
Change-Id: I940bac18e567c55a7cd57e1ccf37203f871c6aec
2026-04-29 14:09:55 +08:00
wujunchen
970a7bc364 chore: add e2e contract gate
Change-Id: Idf5c36b17a5b816e354d07f1e28ff4011e54f32e
2026-04-29 14:08:38 +08:00
wujunchen
3bdd6d67c6 fix: localize batch prompt confirmation
Change-Id: I13b546785c5984a9d0cb5abaffa843f9ad7655d0
2026-04-29 13:58:10 +08:00
wujunchen
2bf4e86451 fix: confirm before overwriting exports
Change-Id: I922fca90e46a8982686a3449b00ed793e924097b
2026-04-29 13:56:36 +08:00
wujunchen
5304ed3a48 fix: localize remaining action labels
Change-Id: I4ac6db14c4aede9a106e8023effac3c99d3c037a
2026-04-29 13:54:53 +08:00
wujunchen
2d39a91aed test: split architecture guard tests
Change-Id: Ibde4ae79f202ae0e26658ab47bbcf5b52ef9effe
2026-04-29 13:53:19 +08:00
wujunchen
39ddd4289e fix: count batch generation failures
Change-Id: Ie6b29cb3efc22fe72250e9c63c1e82c71b24a356
2026-04-29 13:51:33 +08:00
fancivez
ceea8d6ff0 chore: bump version to 1.0.9
Change-Id: I5bd7ac74eb9101ffeeb99ef24518f68e91b9c101
2026-04-27 20:41:14 +08:00
fancivez
dae5175b6c fix: add void operator to all floating promises in callbacks
Satisfies @typescript-eslint/no-floating-promises for fire-and-forget
async calls (runForFile, copyToClipboard, scrollEditorToLine, etc.)

Change-Id: I758cc8e1ae49deacd7e8527abcda90c0e3d4fe1c
2026-04-27 20:39:14 +08:00
wujunchen
f8bf0f99ca chore: bump version to 1.0.8
Change-Id: I0eedef0d4ad19f84f189f8c95a4a1f69b6d09eb4
2026-04-27 20:27:52 +08:00
wujunchen
0801b644f6 fix: address CR findings — bump script, test runner, temp cleanup
- bump-version.mjs: reject duplicate versions (use --force to override),
  auto git-add manifest.json + versions.json for npm version hook
- run-tests.mjs: use execFileSync to handle paths with spaces
- test-setup.js: clean up esbuild temp directory on process exit

Change-Id: I44410b550d09487dd7f5e30a8f9385a2c500252a
2026-04-27 20:19:53 +08:00
wujunchen
199ab73188 refactor: split main.test.js into focused test files
Reduce main.test.js from 560 to 70 lines by keeping only architecture
guards and export smoke tests. Moved provider error tests, Anthropic
tool-use parsing, and cancellationNoticeKey tests to providers.test.js.
Settings, cache, and other tests were already covered in their
dedicated test files.

Change-Id: I8f0f3c576dec7fcf5a4c6e0c2b6071601940349d
2026-04-27 19:59:46 +08:00
wujunchen
f5f974fd74 build: remove __test barrel from production bundle
Tests now load src/test-exports.ts via esbuild buildSync instead of
importing __test from the production main.js. This removes ~60 internal
symbols and all test-only module code from the shipped plugin bundle.

Change-Id: I2caf5232f6894c7514215e1751f2e268b74d7dc4
2026-04-27 19:57:28 +08:00
wujunchen
0bf5ab12b1 lint: extend Biome coverage to tests/ and scripts/
Add tests/**/*.js and scripts/**/*.mjs to biome.json includes and
update npm lint scripts to cover these directories. Auto-fixed all
formatting issues in 26 files.

Change-Id: I8c2763cb9683616a8abc1484d91bf8af79bb590e
2026-04-27 19:53:35 +08:00
wujunchen
b26b241c7f refactor: centralize obsidian mock into tests/obsidian-mock.js
Extracts the duplicated Module._load obsidian stub from main.test.js,
generation-job-manager.test.js, and test-setup.js into a single shared
file. All three now require('./obsidian-mock') instead of defining
their own copy.

Change-Id: I8ac38f779299ac250963357eee9f7824e13c7d33
2026-04-27 19:52:34 +08:00
wujunchen
e10cbbbe9c build: add version bump script
npm run version <x.y.z> atomically updates manifest.json and
versions.json. Replaces the manual three-file version dance.

Change-Id: Ib894147f654b284d8f4c1df95bd8ff5da80d5dcc
2026-04-27 19:51:11 +08:00
wujunchen
e5f5af43ca ci: add tag-triggered release workflow
On tag push (e.g. 1.0.7), builds from source, runs typecheck + tests,
then creates a GitHub Release with main.js, main.js.map, manifest.json,
and styles.css attached. This replaces the need to commit main.js to
the repository.

Change-Id: I8ccbdddaa45267d2738cc43a4e7fccd1d35346ab
2026-04-27 19:50:17 +08:00
wujunchen
f6ce405249 build: remove main.js from git tracking
main.js is now a build artifact produced by esbuild, not tracked in
git. The release workflow (next commit) will build and attach it to
GitHub Releases. Also removes the check-build-artifacts script that
enforced main.js stay in sync.

Change-Id: I35f7dede09c5ac3c2d788c11248e2ed4931a95bc
2026-04-27 19:49:45 +08:00
wujunchen
098fee763c build: enable linked source maps for production builds
Generates main.js.map alongside main.js so error stack traces in the
Obsidian developer console point to original source locations.

Change-Id: I3f63196bfa421c2ac69aee371e4c294d42ee629c
2026-04-27 19:48:26 +08:00
wujunchen
f9e33d4c5d build: align tsconfig target and esbuild target to ES2022
Both were ES2020 while lib was already ES2022, creating a mismatch
that could allow using APIs esbuild wouldn't polyfill. Obsidian 1.4+
runs Electron 25+ which fully supports ES2022.

Change-Id: I732538280146c6a91cf52cc52ef124f6a364dc5e
2026-04-27 19:47:41 +08:00
88 changed files with 13425 additions and 1556 deletions

21
.c8rc.json Normal file
View file

@ -0,0 +1,21 @@
{
"all": false,
"src": ["src"],
"include": ["src/**/*.ts", "main.ts"],
"exclude": [
"src/test-exports.ts",
"tests/**",
"scripts/**",
".e2e/**",
"**/node_modules/**"
],
"excludeAfterRemap": true,
"reporter": ["text-summary", "text", "html", "json-summary"],
"reportsDirectory": "coverage",
"tempDirectory": "coverage/.tmp",
"check-coverage": true,
"branches": 100,
"statements": 0,
"lines": 0,
"functions": 0
}

65
.e2e/README.md Normal file
View file

@ -0,0 +1,65 @@
# E2E Contract
This repository uses `.e2e/run.sh` as the project-local runtime entry point.
The runner clears stale artifacts, executes the release-grade test pipeline
(build → typecheck → unit/component/contract category suites → headless
product-shell smoke → optional live smoke), records logs under
`.e2e/results/`, and writes `.e2e/artifact.json` in CTRF format with one entry
per real evidence source.
Run the host-neutral gate with:
```bash
npm run e2e
```
The gate is self-contained in this repository. It does not depend on local
agent skills or an external validator checkout.
Generated artifacts under `.e2e/artifact.json` and `.e2e/results/` are runtime
evidence and are intentionally ignored by git.
## Test Categories
The source of truth for ordinary test classification is `tests/catalog.json`.
- `unit`: pure logic tests with no Obsidian runtime, filesystem, process, or
provider boundary.
- `component`: plugin components exercised with controlled adapters, job
managers, or Obsidian shims.
- `contract`: provider protocols, CLI command shapes, exported test surfaces,
and architecture invariants.
- `e2e`: default project-local product-shell smoke. It installs the built plugin
package into a disposable Vault filesystem and boots the packaged `main.js`
against a recording Obsidian API shim. This checks package/install/lifecycle
boundaries, but it is not a full Obsidian GUI run.
- `live`: opt-in check against a real local Vault install. It verifies plugin
files under `.obsidian/plugins/parallel-reader/` and does not launch Obsidian,
call a provider, or run GUI interactions. Run with `TEST_LIVE=1` and set
`OBSIDIAN_VAULT_PATH` when the default iCloud Vault path is not the target.
Useful commands:
```bash
npm run e2e
npm run test:unit
npm run test:component
npm run test:contract
npm run test:e2e
TEST_LIVE=1 npm run test:live
```
The `.e2e` gate emits one CTRF entry per real evidence source so risk-tag
coverage maps to actual runs:
| Entry | Risk tags |
|-------|-----------|
| `build and typecheck` | `boundary_io, wiring, regression` |
| `unit category` | `regression` |
| `component category` | `data_integrity, wiring, resource_lifecycle, regression` |
| `contract category` | `contract, wiring, regression` |
| `headless product-shell e2e smoke` | `boundary_io, wiring, resource_lifecycle, regression` |
| `live Vault install smoke` (skipped unless `TEST_LIVE=1`) | `boundary_io, contract, regression` |
`npm test` remains a developer-facing convenience that runs the same build,
typecheck, and category suites without the e2e/live legs.

77
.e2e/cases/live/run.mjs Normal file
View file

@ -0,0 +1,77 @@
import { createHash } from 'crypto';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import os from 'os';
import path from 'path';
const repoRoot = path.resolve(import.meta.dirname, '../../..');
const resultPath = path.resolve(repoRoot, process.env.LIVE_E2E_RESULT || '.e2e/results/live.json');
const startedAt = Date.now();
function writeResult(result) {
mkdirSync(path.dirname(resultPath), { recursive: true });
writeFileSync(resultPath, JSON.stringify(result, null, 2) + '\n');
}
function sha256(filePath) {
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
}
function defaultVaultPath() {
return path.join(os.homedir(), 'Library/Mobile Documents/iCloud~md~obsidian/Documents/Vault');
}
function main() {
if (process.env.TEST_LIVE !== '1') {
writeResult({
name: 'live Vault install smoke',
category: 'live',
status: 'skipped',
durationMs: Date.now() - startedAt,
reason: 'Set TEST_LIVE=1 to check a real local Vault installation.',
});
return;
}
const vaultRoot = process.env.OBSIDIAN_VAULT_PATH || defaultVaultPath();
const pluginDir = path.join(vaultRoot, '.obsidian/plugins/parallel-reader');
const files = ['main.js', 'manifest.json', 'styles.css'];
const missing = files.filter((file) => !existsSync(path.join(pluginDir, file)));
if (missing.length) {
writeResult({
name: 'live Vault install smoke',
category: 'live',
status: 'failed',
durationMs: Date.now() - startedAt,
vaultRoot,
missing,
});
process.exit(1);
}
const manifest = JSON.parse(readFileSync(path.join(pluginDir, 'manifest.json'), 'utf8'));
const sourceManifest = JSON.parse(readFileSync(path.join(repoRoot, 'manifest.json'), 'utf8'));
const hashChecks = files.map((file) => ({
file,
sourceSha256: sha256(path.join(repoRoot, file)),
installedSha256: sha256(path.join(pluginDir, file)),
}));
const mismatched = hashChecks.filter((check) => check.sourceSha256 !== check.installedSha256);
writeResult({
name: 'live Vault install smoke',
category: 'live',
status: mismatched.length ? 'failed' : 'passed',
durationMs: Date.now() - startedAt,
vaultRoot,
manifest: {
installedId: manifest.id,
installedVersion: manifest.version,
sourceVersion: sourceManifest.version,
},
hashChecks,
});
if (mismatched.length) process.exit(1);
}
main();

View file

@ -0,0 +1,485 @@
import { createHash } from 'crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, copyFileSync } from 'fs';
import Module, { createRequire } from 'module';
import { tmpdir } from 'os';
import path from 'path';
const require = createRequire(import.meta.url);
const repoRoot = path.resolve(import.meta.dirname, '../../..');
const resultPath = path.resolve(repoRoot, process.env.PRODUCT_SHELL_RESULT || '.e2e/results/product-shell.json');
const startedAt = Date.now();
function sha256(filePath) {
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
}
function writeResult(result) {
mkdirSync(path.dirname(resultPath), { recursive: true });
writeFileSync(resultPath, JSON.stringify(result, null, 2) + '\n');
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
class FakeElement {
constructor(tag = 'div') {
this.tagName = tag.toUpperCase();
this.classList = new Set();
this.attrs = new Map();
this.children = [];
this.parent = null;
this._listeners = new Map();
this._textContent = '';
this._title = '';
}
get textContent() {
if (this._textContent && this.children.length === 0) return this._textContent;
return this.children.map((c) => c.textContent).join('');
}
set textContent(value) {
this._textContent = String(value);
this.children = [];
}
setText(text) {
this.textContent = text;
}
empty() {
for (const child of this.children) child.parent = null;
this.children = [];
this._textContent = '';
}
addClass(cls) {
if (!cls) return;
for (const part of String(cls).split(/\s+/)) if (part) this.classList.add(part);
}
removeClass(cls) {
if (!cls) return;
for (const part of String(cls).split(/\s+/)) this.classList.delete(part);
}
toggleClass(cls, force) {
if (force === true) this.addClass(cls);
else if (force === false) this.removeClass(cls);
else if (this.classList.has(cls)) this.removeClass(cls);
else this.addClass(cls);
}
hasClass(cls) {
return this.classList.has(cls);
}
setAttr(name, value) {
this.attrs.set(name, String(value));
}
getAttr(name) {
return this.attrs.has(name) ? this.attrs.get(name) : null;
}
appendChild(child) {
if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child);
child.parent = this;
this.children.push(child);
return child;
}
createEl(tag, options = {}) {
const child = new FakeElement(tag);
if (options.cls) child.addClass(options.cls);
if (options.text != null) child.setText(options.text);
if (options.title != null) child._title = String(options.title);
if (options.href != null) child.setAttr('href', options.href);
return this.appendChild(child);
}
createDiv(options = {}) {
return this.createEl('div', options);
}
createSpan(options = {}) {
return this.createEl('span', options);
}
addEventListener(type, handler) {
if (typeof handler !== 'function') return;
if (!this._listeners.has(type)) this._listeners.set(type, []);
this._listeners.get(type).push(handler);
}
removeEventListener(type, handler) {
const list = this._listeners.get(type);
if (!list) return;
this._listeners.set(
type,
list.filter((h) => h !== handler),
);
}
focus() {
/* recorded by view.ts focusSummaryPane which checks typeof container.focus === 'function' */
}
querySelector(selector) {
const match = this._matcher(selector);
return this._findFirst(match);
}
querySelectorAll(selector) {
const match = this._matcher(selector);
const out = [];
this._collect(match, out);
return out;
}
_matcher(selector) {
const trimmed = String(selector).trim();
if (trimmed.startsWith('.')) {
const cls = trimmed.slice(1);
return (el) => el.classList.has(cls);
}
const tag = trimmed.toUpperCase();
return (el) => el.tagName === tag;
}
_findFirst(match) {
for (const child of this.children) {
if (match(child)) return child;
const nested = child._findFirst(match);
if (nested) return nested;
}
return null;
}
_collect(match, out) {
for (const child of this.children) {
if (match(child)) out.push(child);
child._collect(match, out);
}
}
snapshot() {
return {
tag: this.tagName.toLowerCase(),
classes: [...this.classList].sort(),
attrs: Object.fromEntries(this.attrs),
text: this._textContent || undefined,
children: this.children.map((c) => c.snapshot()),
};
}
}
class DataAdapter {
constructor(vaultRoot) {
this.vaultRoot = vaultRoot;
}
fullPath(filePath) {
return path.join(this.vaultRoot, filePath);
}
async exists(filePath) {
return existsSync(this.fullPath(filePath));
}
async mkdir(filePath) {
mkdirSync(this.fullPath(filePath), { recursive: true });
}
async read(filePath) {
return readFileSync(this.fullPath(filePath), 'utf8');
}
async write(filePath, content) {
mkdirSync(path.dirname(this.fullPath(filePath)), { recursive: true });
writeFileSync(this.fullPath(filePath), content);
}
async remove(filePath) {
rmSync(this.fullPath(filePath), { force: true });
}
}
function makeObsidianStub(record) {
class Plugin {
constructor(app, manifest) {
this.app = app;
this.manifest = manifest;
}
async loadData() {
return {};
}
async saveData(data) {
record.savedData = data;
}
addRibbonIcon(icon, title, callback) {
record.ribbonIcons.push({ icon, title, callback });
}
registerView(type, factory) {
record.viewFactories.set(type, factory);
record.views.push({ type });
}
addCommand(command) {
record.commands.push(command);
}
addSettingTab(tab) {
record.settingTabs.push(tab);
}
registerEvent(eventRef) {
record.events.push(eventRef);
}
}
class ItemView {
constructor(leaf) {
this.leaf = leaf;
this.containerEl = new FakeElement('div');
this.containerEl.appendChild(new FakeElement('div'));
this.containerEl.appendChild(new FakeElement('div'));
}
}
class PluginSettingTab {
constructor(app, plugin) {
this.app = app;
this.plugin = plugin;
this.containerEl = new FakeElement('div');
}
}
class Setting {}
class Notice {
constructor(message) {
record.notices.push(typeof message === 'string' ? message : '<non-string>');
}
}
class MarkdownView {}
class TFile {}
class Menu {}
class Modal {}
return {
Plugin,
ItemView,
PluginSettingTab,
Setting,
Notice,
MarkdownView,
TFile,
Menu,
Modal,
MarkdownRenderer: { render: async () => {} },
requestUrl: async () => {
throw new Error('requestUrl is not available in product-shell smoke');
},
setIcon: () => {},
};
}
function makeApp(vaultRoot) {
const adapter = new DataAdapter(vaultRoot);
return {
vault: {
adapter,
configDir: '.obsidian',
on: (name) => ({ scope: 'vault', name }),
read: async (file) => adapter.read(file.path),
getMarkdownFiles: () => [],
getAbstractFileByPath: () => null,
},
workspace: {
on: (name) => ({ scope: 'workspace', name }),
getActiveViewOfType: () => null,
getLeavesOfType: () => [],
getRightLeaf: () => null,
revealLeaf: async () => {},
setActiveLeaf: () => {},
getLeaf: () => null,
},
};
}
function installPackage(vaultRoot) {
const pluginDir = path.join(vaultRoot, '.obsidian/plugins/parallel-reader');
mkdirSync(pluginDir, { recursive: true });
mkdirSync(path.join(vaultRoot, 'Reading'), { recursive: true });
writeFileSync(path.join(vaultRoot, '.obsidian/community-plugins.json'), JSON.stringify(['parallel-reader'], null, 2));
writeFileSync(path.join(vaultRoot, 'Reading/source.md'), '# Source\n\nThis note exercises plugin package installation.\n');
const files = ['main.js', 'main.js.map', 'manifest.json', 'styles.css'];
for (const file of files) {
const source = path.join(repoRoot, file);
assert(existsSync(source), `missing build artifact: ${file}`);
assert(statSync(source).size > 0, `empty build artifact: ${file}`);
copyFileSync(source, path.join(pluginDir, file));
}
return { pluginDir, files };
}
const SAFE_COMMANDS = ['card-prev', 'card-next', 'card-jump', 'clear-all'];
const UNSAFE_COMMANDS = ['run', 'regen', 'open-view', 'export-current', 'copy-current-markdown', 'batch-generate'];
async function exerciseView(record, plugin) {
const factory = record.viewFactories.get('parallel-reader-view');
assert(typeof factory === 'function', 'view factory is not a function');
const leaf = { containerEl: new FakeElement('div') };
const view = factory(leaf);
assert(view, 'view factory returned no view');
assert(typeof view.onOpen === 'function', 'view has no onOpen');
await view.onOpen();
const container = view.containerEl.children[1];
assert(container.hasClass('parallel-reader-container'), 'container missing parallel-reader-container class');
assert(container.getAttr('tabindex') === '0', 'container missing tabindex');
const empty = container.querySelector('.parallel-reader-empty');
assert(empty, 'empty state element not rendered');
const heading = empty.querySelector('h3');
assert(heading && heading.textContent, 'empty state heading missing');
const paragraph = empty.querySelector('p');
assert(paragraph && paragraph.textContent, 'empty state paragraph missing');
const code = empty.querySelector('code');
assert(code && code.textContent, 'empty state code hint missing');
await view.onClose();
return {
container: {
classes: [...container.classList].sort(),
tabindex: container.getAttr('tabindex'),
childCount: container.children.length,
},
empty: empty.snapshot(),
};
}
async function invokeSafeCommands(record, plugin) {
const byId = new Map(record.commands.map((cmd) => [cmd.id, cmd]));
const invoked = [];
for (const id of SAFE_COMMANDS) {
const cmd = byId.get(id);
assert(cmd && typeof cmd.callback === 'function', `safe command ${id} missing callback`);
const startedAt = Date.now();
let error = null;
try {
await cmd.callback();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
invoked.push({ id, durationMs: Date.now() - startedAt, error });
}
return invoked;
}
function recordUnsafeCommands(record) {
const byId = new Map(record.commands.map((cmd) => [cmd.id, cmd]));
return UNSAFE_COMMANDS.map((id) => ({
id,
registered: byId.has(id),
attempted: false,
reason: 'requires workspace/network mocks beyond product-shell shim',
}));
}
async function main() {
const vaultRoot = mkdtempSync(path.join(tmpdir(), 'parallel-reader-e2e-vault-'));
const record = {
commands: [],
events: [],
notices: [],
ribbonIcons: [],
settingTabs: [],
viewFactories: new Map(),
views: [],
};
try {
const { pluginDir, files } = installPackage(vaultRoot);
const manifestPath = path.join(pluginDir, 'manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
assert(manifest.id === 'parallel-reader', `unexpected manifest id: ${manifest.id}`);
assert(manifest.isDesktopOnly === true, 'manifest must remain desktop-only');
const originalLoad = Module._load;
const obsidianStub = makeObsidianStub(record);
Module._load = function load(request, parent, isMain) {
if (request === 'obsidian') return obsidianStub;
return originalLoad.call(this, request, parent, isMain);
};
let viewRender = null;
let commandsInvoked = null;
let commandsAttempted = null;
try {
const pluginModule = require(path.join(pluginDir, 'main.js'));
const PluginClass = pluginModule.default || pluginModule;
const plugin = new PluginClass(makeApp(vaultRoot), manifest);
await plugin.onload();
assert(record.views.some((view) => view.type === 'parallel-reader-view'), 'parallel-reader view was not registered');
const commandIds = new Set(record.commands.map((command) => command.id));
for (const id of ['run', 'regen', 'open-view', 'export-current', 'copy-current-markdown', 'batch-generate']) {
assert(commandIds.has(id), `missing command registration: ${id}`);
}
assert(record.settingTabs.length === 1, 'settings tab was not registered');
viewRender = await exerciseView(record, plugin);
commandsInvoked = await invokeSafeCommands(record, plugin);
commandsAttempted = recordUnsafeCommands(record);
const failedSafe = commandsInvoked.filter((c) => c.error);
assert(failedSafe.length === 0, `safe commands raised: ${failedSafe.map((c) => `${c.id}=${c.error}`).join('; ')}`);
await plugin.onunload();
} finally {
Module._load = originalLoad;
}
writeResult({
name: 'packaged plugin boot and disposable vault install smoke',
category: 'e2e',
status: 'passed',
durationMs: Date.now() - startedAt,
checks: {
manifest: { id: manifest.id, version: manifest.version, desktopOnly: manifest.isDesktopOnly },
installedFiles: files.map((file) => ({
file,
sha256: sha256(path.join(pluginDir, file)),
})),
registeredCommands: record.commands.map((command) => command.id).sort(),
registeredViews: record.views.map((view) => view.type).sort(),
registeredEventCount: record.events.length,
},
viewRender,
commandsInvoked,
commandsAttempted,
noticeCount: record.notices.length,
});
} catch (error) {
writeResult({
name: 'packaged plugin boot and disposable vault install smoke',
category: 'e2e',
status: 'failed',
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.stack || error.message : String(error),
});
throw error;
} finally {
rmSync(vaultRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

14
.e2e/config.yaml Normal file
View file

@ -0,0 +1,14 @@
schema_version: "2.0"
required_risk_tags:
- boundary_io
- wiring
- data_integrity
- contract
- regression
- resource_lifecycle
preconditions: []
provenance:
cosign_required: false

6
.e2e/gate.sh Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
node .e2e/scripts/gate.mjs --project . "$@"

22
.e2e/hook.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
[ "${STOP_HOOK_ACTIVE:-}" = "true" ] && exit 0
export STOP_HOOK_ACTIVE=true
OUT="$(mktemp)"
set +e
bash .e2e/gate.sh --json >"$OUT"
CODE=$?
set -e
if [ "$CODE" -ne 0 ]; then
cat "$OUT" >&2
rm -f "$OUT"
echo '{"decision":"block","reason":"e2e contract gate failed"}'
exit 2
fi
cat "$OUT" >&2
rm -f "$OUT"
exit 0

68
.e2e/run.sh Executable file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
ARTIFACT=".e2e/artifact.json"
RESULTS_DIR=".e2e/results"
BUILD_LOG="$RESULTS_DIR/build.log"
TESTS_LOG="$RESULTS_DIR/tests-default.log"
TESTS_RESULT="$RESULTS_DIR/tests-default.json"
PRODUCT_SHELL_LOG="$RESULTS_DIR/product-shell.log"
PRODUCT_SHELL_RESULT="$RESULTS_DIR/product-shell.json"
LIVE_LOG="$RESULTS_DIR/live.log"
LIVE_RESULT="$RESULTS_DIR/live.json"
rm -f "$ARTIFACT"
rm -rf "$RESULTS_DIR"
mkdir -p "$RESULTS_DIR"
now_ms() {
node -e 'process.stdout.write(String(Date.now()))'
}
START_MS="$(now_ms)"
set +e
BUILD_START_MS="$(now_ms)"
{ npm run build && npm run typecheck; } >"$BUILD_LOG" 2>&1
BUILD_EXIT=$?
BUILD_END_MS="$(now_ms)"
BUILD_DURATION_MS=$((BUILD_END_MS - BUILD_START_MS))
if [ "$BUILD_EXIT" -eq 0 ]; then
TEST_RESULTS_JSON="$TESTS_RESULT" node scripts/run-tests.mjs >"$TESTS_LOG" 2>&1
TESTS_EXIT=$?
else
TESTS_EXIT=1
echo "skipped: build/typecheck failed" >"$TESTS_LOG"
fi
if [ "$BUILD_EXIT" -eq 0 ]; then
PRODUCT_SHELL_RESULT="$PRODUCT_SHELL_RESULT" node .e2e/cases/product-shell/run.mjs >"$PRODUCT_SHELL_LOG" 2>&1
PRODUCT_SHELL_EXIT=$?
else
PRODUCT_SHELL_EXIT=1
echo "skipped: build/typecheck failed" >"$PRODUCT_SHELL_LOG"
fi
if [ "${TEST_LIVE:-0}" = "1" ]; then
LIVE_E2E_RESULT="$LIVE_RESULT" node .e2e/cases/live/run.mjs >"$LIVE_LOG" 2>&1
LIVE_EXIT=$?
else
LIVE_EXIT=0
fi
set -e
END_MS="$(now_ms)"
export START_MS END_MS
export BUILD_LOG BUILD_EXIT BUILD_DURATION_MS
export TESTS_LOG TESTS_RESULT
export PRODUCT_SHELL_LOG PRODUCT_SHELL_EXIT
export LIVE_LOG LIVE_EXIT
node .e2e/scripts/write-artifact.mjs
if [ "$BUILD_EXIT" -ne 0 ] || [ "$TESTS_EXIT" -ne 0 ] || [ "$PRODUCT_SHELL_EXIT" -ne 0 ] || [ "$LIVE_EXIT" -ne 0 ]; then
exit 1
fi

395
.e2e/scripts/gate.mjs Normal file
View file

@ -0,0 +1,395 @@
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
import { existsSync, readFileSync } from 'fs';
import path from 'path';
const VALID_RISK_TAGS = new Set([
'boundary_io',
'failure_path',
'concurrency',
'wiring',
'regression',
'security',
'data_integrity',
'resource_lifecycle',
'contract',
]);
function parseArgs(argv) {
const args = {
json: false,
project: '.',
required: false,
skipRun: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--json') args.json = true;
else if (arg === '--required') args.required = true;
else if (arg === '--skip-run') args.skipRun = true;
else if (arg === '--project') {
const value = argv[++i];
if (!value) throw new Error('--project requires a value');
args.project = value;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf8'));
}
function parseScalar(value) {
const trimmed = value.trim();
if (trimmed === '[]') return [];
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function loadConfig(configPath) {
const root = {};
let currentList = null;
let currentObject = null;
for (const rawLine of readFileSync(configPath, 'utf8').split(/\r?\n/)) {
const withoutComment = rawLine.replace(/\s+#.*$/, '');
if (!withoutComment.trim()) continue;
const indent = withoutComment.match(/^\s*/)[0].length;
const line = withoutComment.trim();
if (indent === 0) {
currentList = null;
currentObject = null;
const match = line.match(/^([^:]+):(.*)$/);
if (!match) throw new Error(`unsupported config line: ${rawLine}`);
const key = match[1].trim();
const value = match[2].trim();
if (!value) {
if (key === 'required_risk_tags' || key === 'preconditions') {
root[key] = [];
currentList = root[key];
} else {
root[key] = {};
currentObject = root[key];
}
} else {
root[key] = parseScalar(value);
}
} else if (currentList && line.startsWith('- ')) {
currentList.push(parseScalar(line.slice(2)));
} else if (currentObject) {
const match = line.match(/^([^:]+):(.*)$/);
if (!match) throw new Error(`unsupported nested config line: ${rawLine}`);
currentObject[match[1].trim()] = parseScalar(match[2].trim());
} else {
throw new Error(`unsupported config indentation: ${rawLine}`);
}
}
const schemaVersion = String(root.schema_version || '');
if (!schemaVersion) throw new Error('config.schema_version is required');
if (!schemaVersion.startsWith('2.')) throw new Error(`unsupported config schema_version ${schemaVersion}`);
const required = root.required_risk_tags;
if (!Array.isArray(required) || required.length === 0) {
throw new Error('config.required_risk_tags must be a non-empty list');
}
const invalid = required.filter(
(tag) => typeof tag !== 'string' || (!VALID_RISK_TAGS.has(tag) && !tag.startsWith('x-')),
);
if (invalid.length) throw new Error(`unknown required risk tag(s): ${invalid.join(', ')}`);
if (!root.provenance || typeof root.provenance !== 'object') root.provenance = {};
if (!Array.isArray(root.preconditions)) root.preconditions = [];
return root;
}
function loadCtrf(artifactPath) {
const ctrf = readJson(artifactPath);
if (!ctrf || typeof ctrf !== 'object' || Array.isArray(ctrf)) throw new Error('artifact must be a JSON object');
const results = ctrf.results;
if (!results || typeof results !== 'object') throw new Error('artifact.results is required');
if (!results.tool || typeof results.tool !== 'object') throw new Error('artifact.results.tool is required');
if (!results.summary || typeof results.summary !== 'object') throw new Error('artifact.results.summary is required');
if (!Array.isArray(results.tests)) throw new Error('artifact.results.tests must be a list');
return ctrf;
}
function sha256(filePath) {
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
}
function* walkDicts(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
yield value;
for (const child of Object.values(value)) yield* walkDicts(child);
} else if (Array.isArray(value)) {
for (const child of value) yield* walkDicts(child);
}
}
function* iterContracts(ctrf) {
for (const test of ctrf.results.tests) {
const contract = test?.extra?.e2e_contract;
if (contract && typeof contract === 'object' && !Array.isArray(contract)) {
yield [test, contract];
}
}
}
function verifyEvidenceSha256(ctrf, evidenceDir) {
const base = path.resolve(evidenceDir);
const errors = [];
for (const [test, contract] of iterContracts(ctrf)) {
const testName = String(test.name || '<unknown>');
for (const mapping of walkDicts(contract)) {
for (const [key, expected] of Object.entries(mapping)) {
if (!key.endsWith('_sha256') || typeof expected !== 'string') continue;
const pathKey = `${key.slice(0, -7)}_path`;
const relPath = mapping[pathKey];
if (typeof relPath !== 'string' || !relPath) continue;
const filePath = path.resolve(base, relPath);
if (!filePath.startsWith(`${base}${path.sep}`) && filePath !== base) {
errors.push(`${testName}: evidence path escapes base directory ${relPath}`);
continue;
}
if (!existsSync(filePath)) {
errors.push(`${testName}: missing evidence file ${relPath}`);
continue;
}
if (sha256(filePath) !== expected) {
errors.push(`${testName}: ${relPath} sha256 mismatch`);
}
}
}
}
return errors;
}
function checkInternalConsistency(ctrf) {
const errors = [];
const summary = ctrf.results.summary;
const tests = ctrf.results.tests;
const statuses = ['passed', 'failed', 'skipped', 'pending', 'other'];
for (const field of ['tests', ...statuses]) {
if (!(field in summary)) errors.push(`summary.${field} is required`);
}
const countTotal = statuses.reduce((sum, status) => sum + Number(summary[status] || 0), 0);
const declaredTotal = Number(summary.tests || tests.length);
if (declaredTotal !== tests.length) {
errors.push(`summary.tests=${declaredTotal} but artifact has ${tests.length} test entries`);
}
if (countTotal !== tests.length) {
errors.push(`summary status counts total ${countTotal} but artifact has ${tests.length} test entries`);
}
if (Number(summary.failed || 0) > 0) {
errors.push(`summary.failed=${summary.failed}; e2e gate requires all tests to pass`);
}
for (const [test, contract] of iterContracts(ctrf)) {
if (test.status !== 'passed') continue;
const command = contract.command_recorded;
if (!command || typeof command !== 'object') continue;
if (!Object.hasOwn(command, 'exit_code')) {
errors.push(`${test.name || '<unknown>'}: command_recorded.exit_code missing`);
continue;
}
const expected = Object.hasOwn(contract, 'expected_exit_code') ? contract.expected_exit_code : 0;
if (command.exit_code !== expected) {
errors.push(`${test.name || '<unknown>'}: exit_code ${command.exit_code} != expected ${expected}`);
}
}
return errors;
}
function checkRiskCoverage(config, ctrf) {
const required = new Set(config.required_risk_tags || []);
const covered = new Set();
for (const test of ctrf.results.tests) {
if (test.status !== 'passed') continue;
for (const tag of test.tags || []) {
if (typeof tag === 'string' && tag.startsWith('risk:')) covered.add(tag.slice('risk:'.length));
}
}
return [...required].filter((tag) => !covered.has(tag)).sort();
}
function runCheck(configPath, artifactPath, evidenceDir) {
const result = {
artifact_path: artifactPath,
config_path: configPath,
failures: [],
missing: [],
mode: 'ctrf',
schema_version: '2.0',
status: 'failed',
steps: {},
};
let config;
let ctrf;
try {
config = loadConfig(configPath);
ctrf = loadCtrf(artifactPath);
result.steps.parse = 'pass';
} catch (error) {
result.status = 'invalid';
result.failures.push(error instanceof Error ? error.message : String(error));
result.steps.parse = 'fail';
return [result, 3];
}
if (config.provenance?.cosign_required) {
result.failures.push('cosign provenance verification is not available in this repo-local gate');
result.steps.provenance = 'fail';
return [result, 1];
}
result.steps.provenance = 'skipped';
const evidenceErrors = verifyEvidenceSha256(ctrf, evidenceDir);
if (evidenceErrors.length) {
result.status = 'invalid';
result.failures.push(...evidenceErrors);
result.steps.evidence_sha256 = 'fail';
return [result, 4];
}
result.steps.evidence_sha256 = 'pass';
const consistencyErrors = checkInternalConsistency(ctrf);
if (consistencyErrors.length) {
result.failures.push(...consistencyErrors);
result.steps.internal_consistency = 'fail';
return [result, 1];
}
result.steps.internal_consistency = 'pass';
const missing = checkRiskCoverage(config, ctrf);
result.missing = missing;
if (missing.length) {
result.failures.push(`missing required risk tags: ${missing.join(', ')}`);
result.steps.risk_coverage = 'fail';
return [result, 1];
}
result.steps.risk_coverage = 'pass';
result.status = 'pass';
return [result, 0];
}
function runPreconditions(config, projectRoot) {
const records = [];
for (const item of config.preconditions || []) {
if (!item || typeof item !== 'object' || !item.check) continue;
const completed = spawnSync(String(item.check), {
cwd: projectRoot,
encoding: 'utf8',
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
records.push({
check: item.check,
exit_code: completed.status ?? 1,
name: item.name || 'precondition',
remediation: item.remediation || '',
status: completed.status === 0 ? 'pass' : 'fail',
stderr: (completed.stderr || '').trim(),
stdout: (completed.stdout || '').trim(),
});
}
return records;
}
function runGate(args) {
const projectRoot = path.resolve(args.project);
const e2eDir = path.join(projectRoot, '.e2e');
const configPath = path.join(e2eDir, 'config.yaml');
const artifactPath = path.join(e2eDir, 'artifact.json');
const result = {
artifact_path: artifactPath,
check_exit_code: null,
config_path: configPath,
exit_code: 1,
missing: [],
mode: 'ctrf',
preconditions: [],
run_exit_code: null,
schema_version: '2.0',
status: 'failed',
};
if (!existsSync(configPath)) {
result.status = args.required ? 'missing_contract' : 'no_contract';
result.exit_code = args.required ? 3 : 0;
return [result, result.exit_code];
}
let config;
try {
config = loadConfig(configPath);
} catch (error) {
result.status = 'invalid';
result.failures = [error instanceof Error ? error.message : String(error)];
result.exit_code = 3;
return [result, 3];
}
const preconditions = runPreconditions(config, projectRoot);
result.preconditions = preconditions;
if (preconditions.some((item) => item.status !== 'pass')) {
result.status = 'precondition_failed';
result.exit_code = 2;
return [result, 2];
}
let runExit = 0;
if (!args.skipRun) {
const runScript = path.join(e2eDir, 'run.sh');
if (!existsSync(runScript)) {
result.status = 'invalid';
result.failures = ['.e2e/run.sh is missing'];
result.exit_code = 3;
return [result, 3];
}
const completed = spawnSync('bash', [runScript], {
cwd: projectRoot,
encoding: 'utf8',
env: { ...process.env, E2E_CONTRACT_ACTIVE: 'true' },
stdio: ['ignore', 'pipe', 'pipe'],
});
runExit = completed.status ?? 1;
result.run_stdout = (completed.stdout || '').trim();
result.run_stderr = (completed.stderr || '').trim();
}
result.run_exit_code = runExit;
const [check, checkExit] = runCheck(configPath, artifactPath, e2eDir);
result.check = check;
result.check_exit_code = checkExit;
result.missing = check.missing || [];
result.exit_code = checkExit || (runExit !== 0 ? 1 : 0);
result.status = result.exit_code === 0 ? 'pass' : 'fail';
return [result, result.exit_code];
}
try {
const args = parseArgs(process.argv.slice(2));
const [result, exitCode] = runGate(args);
process.stdout.write(JSON.stringify(result, null, args.json ? 2 : 0) + '\n');
process.exit(exitCode);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(3);
}

View file

@ -0,0 +1,206 @@
import { createHash } from 'crypto';
import { existsSync, readFileSync, writeFileSync } from 'fs';
const resultsDir = '.e2e/results';
const catalogPath = 'tests/catalog.json';
const RISK_TAGS_BY_CATEGORY = Object.freeze({
unit: ['regression'],
component: ['data_integrity', 'wiring', 'resource_lifecycle', 'regression'],
contract: ['contract', 'wiring', 'regression'],
});
const PRODUCT_SHELL_RISK_TAGS = ['boundary_io', 'wiring', 'resource_lifecycle', 'regression'];
const LIVE_RISK_TAGS = ['boundary_io', 'contract', 'regression'];
const BUILD_RISK_TAGS = ['boundary_io', 'wiring', 'regression'];
function readJson(path, fallback = null) {
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch {
return fallback;
}
}
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
function rel(path) {
return path.replace(/^\.e2e\//, '');
}
function evidence(logPath, resultPath = '') {
const value = {};
if (logPath && existsSync(logPath)) {
value.log_path = rel(logPath);
value.log_sha256 = sha256(logPath);
}
if (resultPath && existsSync(resultPath)) {
value.result_path = rel(resultPath);
value.result_sha256 = sha256(resultPath);
}
return value;
}
function makeTest({ name, status, durationMs, tags, command, exitCode, logPath, resultPath, sourceFiles, extra = {} }) {
return {
name,
status,
duration: Math.max(0, durationMs || 0),
tags: status === 'passed' ? tags.map((tag) => `risk:${tag}`) : [],
extra: {
e2e_contract: {
command_recorded: {
command,
exit_code: Number(exitCode),
},
expected_exit_code: 0,
evidence: evidence(logPath, resultPath),
source_files: sourceFiles,
...extra,
},
},
};
}
function makeSkippedTest(name, reason) {
return {
name,
status: 'skipped',
duration: 0,
tags: [],
extra: {
e2e_contract: {
skip_reason: reason,
},
},
};
}
function buildAndTypecheckTest() {
const logPath = process.env.BUILD_LOG || `${resultsDir}/build.log`;
const exitCode = Number(process.env.BUILD_EXIT || 1);
const durationMs = Number(process.env.BUILD_DURATION_MS || 0);
return makeTest({
name: 'build and typecheck',
status: exitCode === 0 ? 'passed' : 'failed',
durationMs,
tags: BUILD_RISK_TAGS,
command: 'npm run build && npm run typecheck',
exitCode,
logPath,
sourceFiles: ['package.json', 'esbuild.config.mjs', 'tsconfig.json'],
extra: { category: 'build' },
});
}
function categoryTest(category, summary, defaultCategories) {
const tags = RISK_TAGS_BY_CATEGORY[category];
if (!tags) throw new Error(`Unmapped test category: ${category}`);
const stats = summary?.byCategory?.[category] || { tests: 0, passed: 0, failed: 0, files: [] };
const exitCode = stats.failed === 0 && stats.tests > 0 ? 0 : 1;
const durationMs = (summary?.results || [])
.filter((r) => r.category === category)
.reduce((sum, r) => sum + (r.durationMs || 0), 0);
const recordedCommand = `TEST_RESULTS_JSON=${process.env.TESTS_RESULT || `${resultsDir}/tests-default.json`} node scripts/run-tests.mjs (categories: ${defaultCategories.join(',')})`;
return makeTest({
name: `${category} category`,
status: exitCode === 0 ? 'passed' : 'failed',
durationMs,
tags,
command: recordedCommand,
exitCode,
logPath: process.env.TESTS_LOG || `${resultsDir}/tests-default.log`,
resultPath: process.env.TESTS_RESULT || `${resultsDir}/tests-default.json`,
sourceFiles: stats.files.map((f) => `tests/${f}`),
extra: { category, stats: { tests: stats.tests, passed: stats.passed, failed: stats.failed } },
});
}
function productShellTest() {
const logPath = process.env.PRODUCT_SHELL_LOG || `${resultsDir}/product-shell.log`;
const resultPath = `${resultsDir}/product-shell.json`;
const exitCode = Number(process.env.PRODUCT_SHELL_EXIT || 1);
const result = readJson(resultPath, {});
return makeTest({
name: 'headless product-shell e2e smoke',
status: result.status === 'passed' && exitCode === 0 ? 'passed' : 'failed',
durationMs: result.durationMs || 0,
tags: PRODUCT_SHELL_RISK_TAGS,
command: 'node .e2e/cases/product-shell/run.mjs',
exitCode,
logPath,
resultPath,
sourceFiles: ['main.js', 'manifest.json', 'styles.css', '.e2e/cases/product-shell/run.mjs'],
extra: {
category: 'e2e',
checks: result.checks || {},
viewRender: result.viewRender || null,
commandsInvoked: result.commandsInvoked || null,
commandsAttempted: result.commandsAttempted || null,
},
});
}
function liveTest() {
if (process.env.TEST_LIVE !== '1') {
return makeSkippedTest('live Vault install smoke', 'Set TEST_LIVE=1 to check a real local Vault installation.');
}
const logPath = process.env.LIVE_LOG || `${resultsDir}/live.log`;
const resultPath = `${resultsDir}/live.json`;
const exitCode = Number(process.env.LIVE_EXIT || 1);
const result = readJson(resultPath, {});
return makeTest({
name: 'live Vault install smoke',
status: result.status === 'passed' && exitCode === 0 ? 'passed' : 'failed',
durationMs: result.durationMs || 0,
tags: LIVE_RISK_TAGS,
command: 'TEST_LIVE=1 node .e2e/cases/live/run.mjs',
exitCode,
logPath,
resultPath,
sourceFiles: ['.e2e/cases/live/run.mjs'],
extra: {
category: 'live',
checks: result,
},
});
}
const catalog = readJson(catalogPath);
if (!catalog) throw new Error(`failed to read ${catalogPath}`);
const defaultCategories = catalog.defaultCategories || ['unit', 'component', 'contract'];
const testsSummary = readJson(`${resultsDir}/tests-default.json`, null);
const tests = [
buildAndTypecheckTest(),
...defaultCategories.map((category) => categoryTest(category, testsSummary, defaultCategories)),
productShellTest(),
liveTest(),
];
const summary = {
tests: tests.length,
passed: tests.filter((test) => test.status === 'passed').length,
failed: tests.filter((test) => test.status === 'failed').length,
pending: 0,
skipped: tests.filter((test) => test.status === 'skipped').length,
other: tests.filter((test) => !['passed', 'failed', 'skipped'].includes(test.status)).length,
};
const artifact = {
reportFormat: 'CTRF',
specVersion: '0.0.0',
results: {
tool: {
name: 'obsidian-parallel-reader-e2e',
version: '3',
},
summary,
tests,
},
};
writeFileSync('.e2e/artifact.json', JSON.stringify(artifact, null, 2) + '\n');

View file

@ -11,7 +11,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@ -20,14 +21,27 @@ jobs:
- run: npm ci - run: npm ci
- name: Build
run: npm run build
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test:only
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Lint (Obsidian review rules)
run: npm run lint:obsidian
- name: Lint (Obsidian strict review)
run: npm run lint:obsidian:review:all
- name: Coverage (branch threshold)
run: npm run coverage
- name: E2E gate
run: timeout 600 npm run e2e
- name: Upload e2e artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-${{ github.run_id }}
if-no-files-found: ignore
path: |
.e2e/artifact.json
.e2e/results/

54
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Release
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Lint
run: npm run lint
- name: E2E gate
run: timeout 600 npm run e2e
- name: Upload e2e artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-${{ github.run_id }}
if-no-files-found: ignore
path: |
.e2e/artifact.json
.e2e/results/
- name: Create Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
if gh release view "$tag" >/dev/null 2>&1; then
gh release upload "$tag" --clobber main.js main.js.map manifest.json styles.css
else
gh release create "$tag" \
--title "$tag" \
--generate-notes \
main.js main.js.map manifest.json styles.css
fi

17
.gitignore vendored
View file

@ -9,10 +9,25 @@ cache.json
*.swp *.swp
*.swo *.swo
# Node (if build tooling is added later) # Node
node_modules/ node_modules/
dist/ dist/
*.log *.log
# Build artifacts
main.js
main.js.map
# Coverage
coverage/
.test-bundles/
# Agent state # Agent state
.agent/ .agent/
.claude/
.orchestration/
# E2E contract runtime artifacts
.e2e/artifact.json
.e2e/artifact.json.sig
.e2e/results/

View file

@ -1,15 +1,31 @@
<h1 align="center">Obsidian Parallel Reader</h1>
<p align="center"> <p align="center">
<img src="https://img.shields.io/github/v/release/fancive/obsidian-parallel-reader?style=flat-square&color=blue" alt="Release"> <em>Split-view reading for Obsidian — your original note on the left, AI-generated summary cards on the right, with scroll-sync highlighting.</em>
<img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI">
<img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="License">
<img src="https://img.shields.io/github/stars/fancive/obsidian-parallel-reader?style=flat-square" alt="Stars">
</p> </p>
# Obsidian Parallel Reader <p align="center">
<a href="https://github.com/fancive/obsidian-parallel-reader/releases/latest"><img src="https://img.shields.io/github/v/release/fancive/obsidian-parallel-reader?style=flat-square&color=4c1" alt="Latest release"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/releases"><img src="https://img.shields.io/github/downloads/fancive/obsidian-parallel-reader/total?style=flat-square&color=4c1" alt="Total downloads"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI status"></a>
<a href="./LICENSE"><img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="License"></a>
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.8.7-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.8.7+">
</p>
> **[中文文档](./README.zh-CN.md)** <p align="center">
<a href="https://github.com/fancive/obsidian-parallel-reader/stargazers"><img src="https://img.shields.io/github/stars/fancive/obsidian-parallel-reader?style=flat-square" alt="Stars"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/issues"><img src="https://img.shields.io/github/issues/fancive/obsidian-parallel-reader?style=flat-square" alt="Open issues"></a>
<img src="https://img.shields.io/github/last-commit/fancive/obsidian-parallel-reader?style=flat-square" alt="Last commit">
<img src="https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript strict">
<img src="https://img.shields.io/badge/code_style-biome-60a5fa?style=flat-square&logo=biome&logoColor=white" alt="Code style: Biome">
<a href="./README.zh-CN.md"><img src="https://img.shields.io/badge/lang-中文-red?style=flat-square" alt="中文文档"></a>
</p>
Split-view reading for Obsidian — your original note on the left, AI-generated summary cards on the right, with scroll-sync highlighting. <p align="center">
<b>English</b> · <a href="./README.zh-CN.md">中文</a>
</p>
---
Inspired by [this reading workflow demo](https://www.bilibili.com/video/BV1FxoGBVETm/). Inspired by [this reading workflow demo](https://www.bilibili.com/video/BV1FxoGBVETm/).
@ -23,7 +39,7 @@ Inspired by [this reading workflow demo](https://www.bilibili.com/video/BV1FxoGB
- **Rich rendering** — cards render through Obsidian's `MarkdownRenderer`, so tables, bold, code, and wikilinks all work natively. - **Rich rendering** — cards render through Obsidian's `MarkdownRenderer`, so tables, bold, code, and wikilinks all work natively.
- **Card editing** — right-click any card to copy, edit, delete, or jump to source. - **Card editing** — right-click any card to copy, edit, delete, or jump to source.
- **Export** — save cards as a Markdown note in your vault, or copy to clipboard. - **Export** — save cards as a Markdown note in your vault, or copy to clipboard.
- **Bilingual UI** — full Chinese and English support for commands, settings, and notices. - **Multi-language UI and output** — UI supports Auto, Chinese, English, Japanese, Korean, French, German, and Spanish; generated titles, gists, and bullets can use the same fixed languages or follow the source document.
## Quick Start ## Quick Start
@ -109,8 +125,20 @@ npm run build # production build
npm run typecheck # TypeScript strict mode npm run typecheck # TypeScript strict mode
npm run lint # Biome npm run lint # Biome
npm test # build + typecheck + tests npm test # build + typecheck + tests
npm run test:unit
npm run test:component
npm run test:contract
npm run test:e2e # packaged plugin + disposable Vault smoke
``` ```
For CI / release evidence, run the contract gate:
```bash
bash .e2e/gate.sh --json # writes .e2e/artifact.json (gitignored)
```
Add `TEST_LIVE=1` to opt into real local Vault / provider checks.
## Star History ## Star History
<a href="https://www.star-history.com/#fancive/obsidian-parallel-reader&Date"> <a href="https://www.star-history.com/#fancive/obsidian-parallel-reader&Date">

View file

@ -1,15 +1,31 @@
<h1 align="center">Obsidian Parallel Reader</h1>
<p align="center"> <p align="center">
<img src="https://img.shields.io/github/v/release/fancive/obsidian-parallel-reader?style=flat-square&color=blue" alt="Release"> <em>Obsidian 对照阅读插件 — 左边原文、右边 AI 摘要卡片,滚动联动、点击跳转。</em>
<img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI">
<img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="License">
<img src="https://img.shields.io/github/stars/fancive/obsidian-parallel-reader?style=flat-square" alt="Stars">
</p> </p>
# Obsidian Parallel Reader <p align="center">
<a href="https://github.com/fancive/obsidian-parallel-reader/releases/latest"><img src="https://img.shields.io/github/v/release/fancive/obsidian-parallel-reader?style=flat-square&color=4c1" alt="最新版本"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/releases"><img src="https://img.shields.io/github/downloads/fancive/obsidian-parallel-reader/total?style=flat-square&color=4c1" alt="累计下载"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI 状态"></a>
<a href="./LICENSE"><img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="开源协议"></a>
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.8.7-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.8.7+">
</p>
> **[English](./README.md)** <p align="center">
<a href="https://github.com/fancive/obsidian-parallel-reader/stargazers"><img src="https://img.shields.io/github/stars/fancive/obsidian-parallel-reader?style=flat-square" alt="Star 数"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/issues"><img src="https://img.shields.io/github/issues/fancive/obsidian-parallel-reader?style=flat-square" alt="未关闭 issue"></a>
<img src="https://img.shields.io/github/last-commit/fancive/obsidian-parallel-reader?style=flat-square" alt="最近提交">
<img src="https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript strict">
<img src="https://img.shields.io/badge/code_style-biome-60a5fa?style=flat-square&logo=biome&logoColor=white" alt="代码风格 Biome">
<a href="./README.md"><img src="https://img.shields.io/badge/lang-English-blue?style=flat-square" alt="English README"></a>
</p>
Obsidian 对照阅读插件 — 左边原文、右边 AI 摘要卡片,滚动联动、点击跳转。 <p align="center">
<a href="./README.md">English</a> · <b>中文</b>
</p>
---
灵感来自 [这个 B 站视频](https://www.bilibili.com/video/BV1FxoGBVETm/) 的阅读工作流演示。 灵感来自 [这个 B 站视频](https://www.bilibili.com/video/BV1FxoGBVETm/) 的阅读工作流演示。
@ -23,7 +39,7 @@ Obsidian 对照阅读插件 — 左边原文、右边 AI 摘要卡片,滚动
- **Markdown 渲染** — 通过 Obsidian 的 `MarkdownRenderer` 渲染表格、加粗、代码、wikilink 均正常显示。 - **Markdown 渲染** — 通过 Obsidian 的 `MarkdownRenderer` 渲染表格、加粗、代码、wikilink 均正常显示。
- **卡片编辑** — 右键任意卡片:复制、编辑、删除、跳转原文。 - **卡片编辑** — 右键任意卡片:复制、编辑、删除、跳转原文。
- **导出** — 保存为 Vault 中的 Markdown 文件,或复制到剪贴板。 - **导出** — 保存为 Vault 中的 Markdown 文件,或复制到剪贴板。
- **中英双语 UI** — 命令、设置、面板文案全部支持中文和英文 - **多语言 UI 与输出** — UI 支持 Auto、中文、英文、日文、韩文、法文、德文、西班牙文生成的 title/gist/bullets 也可选择这些固定语言,或跟随原文语言
## 快速开始 ## 快速开始
@ -109,8 +125,20 @@ npm run build # 生产构建
npm run typecheck # TypeScript strict 模式 npm run typecheck # TypeScript strict 模式
npm run lint # Biome npm run lint # Biome
npm test # 构建 + 类型检查 + 测试 npm test # 构建 + 类型检查 + 测试
npm run test:unit
npm run test:component
npm run test:contract
npm run test:e2e # 打包插件 + 临时 Vault smoke
``` ```
CI / 发布前跑一次 contract gate 留证据:
```bash
bash .e2e/gate.sh --json # 写出 .e2e/artifact.json已 gitignore
```
`TEST_LIVE=1` 可开启真实本地 Vault / provider 检查。
## Star History ## Star History
<a href="https://www.star-history.com/#fancive/obsidian-parallel-reader&Date"> <a href="https://www.star-history.com/#fancive/obsidian-parallel-reader&Date">

View file

@ -1,7 +1,14 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"files": { "files": {
"includes": ["main.ts", "src/**/*.ts", "esbuild.config.mjs"] "includes": [
"main.ts",
"src/**/*.ts",
"tests/**/*.js",
"scripts/**/*.mjs",
".e2e/scripts/**/*.mjs",
"esbuild.config.mjs"
]
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,

View file

@ -4,21 +4,16 @@ import { builtinModules } from 'module';
const production = process.argv[2] === 'production'; const production = process.argv[2] === 'production';
const watch = process.argv[2] === 'watch'; const watch = process.argv[2] === 'watch';
const external = [ const external = ['obsidian', 'electron', ...builtinModules, ...builtinModules.map((m) => `node:${m}`)];
'obsidian',
'electron',
...builtinModules,
...builtinModules.map(m => `node:${m}`),
];
const context = await esbuild.context({ const context = await esbuild.context({
entryPoints: ['main.ts'], entryPoints: ['main.ts'],
bundle: true, bundle: true,
platform: 'node', platform: 'node',
format: 'cjs', format: 'cjs',
target: 'es2020', target: 'es2022',
external, external,
sourcemap: production ? false : 'inline', sourcemap: production ? 'linked' : 'inline',
treeShaking: true, treeShaking: true,
minify: production, minify: production,
outfile: 'main.js', outfile: 'main.js',

36
eslint.config.mjs Normal file
View file

@ -0,0 +1,36 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ["main.ts", "src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
},
rules: {
// Out-of-scope for an Obsidian plugin lint pass: TS-strict noise
// from JSON.parse / dynamic i18n keys. Biome + tsc cover type safety.
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-return": "off",
},
},
{
ignores: [
"node_modules/",
"main.js",
"main.js.map",
"tests/",
"scripts/",
".e2e/",
".orchestration/",
".agent/",
".codex-tmp/",
],
},
]);

View file

@ -0,0 +1,47 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ["main.ts", "src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
},
rules: {
// Keep this review check close to Obsidian policy rules while avoiding
// project-specific dynamic-type noise that the ReviewBot did not report.
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-return": "off",
// The ReviewBot currently reports underscore-only unused bindings as
// Optional findings, while the published recommended config ignores them.
"@typescript-eslint/no-unused-vars": [
"warn",
{
args: "all",
caughtErrors: "all",
ignoreRestSiblings: true,
},
],
},
},
{
ignores: [
"node_modules/",
"main.js",
"main.js.map",
"tests/",
"scripts/",
".e2e/",
".orchestration/",
".agent/",
".codex-tmp/",
],
},
]);

111
main.js

File diff suppressed because one or more lines are too long

222
main.ts
View file

@ -18,6 +18,7 @@ import {
import { shouldConfirmRegenerate } from './src/cache'; import { shouldConfirmRegenerate } from './src/cache';
import { CacheManager } from './src/cache-manager'; import { CacheManager } from './src/cache-manager';
import { resolveCardAnchors } from './src/cards'; import { resolveCardAnchors } from './src/cards';
import { showGenerationError } from './src/error-ui';
import { cancellationNoticeKey, summarizeDocument } from './src/generation'; import { cancellationNoticeKey, summarizeDocument } from './src/generation';
import { import {
classifyGenerationError, classifyGenerationError,
@ -29,10 +30,16 @@ import { translate } from './src/i18n';
import { cardsToMarkdown } from './src/markdown'; import { cardsToMarkdown } from './src/markdown';
import { confirmRegenerateEditedCards } from './src/modal'; import { confirmRegenerateEditedCards } from './src/modal';
import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll'; import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll';
import { cacheEntryMatches, DEFAULT_SETTINGS, normalizeSettings } from './src/settings'; import {
cacheEntryMatches,
DEFAULT_SETTINGS,
getApiAuthType,
getApiKey,
isApiBackend,
normalizeSettings,
} from './src/settings';
import { ParallelReaderSettingTab } from './src/settings-tab'; import { ParallelReaderSettingTab } from './src/settings-tab';
import type { StreamProgress } from './src/streaming'; import type { StreamProgress } from './src/streaming';
import * as testExports from './src/test-exports';
import type { import type {
CacheEntry, CacheEntry,
ObsidianEditorWithCm, ObsidianEditorWithCm,
@ -40,6 +47,8 @@ import type {
ObsidianMenuItem, ObsidianMenuItem,
PluginSettings, PluginSettings,
ResolvedCard, ResolvedCard,
RunForFileOptions,
RunForFileResult,
} from './src/types'; } from './src/types';
import { copyToClipboard } from './src/ui-helpers'; import { copyToClipboard } from './src/ui-helpers';
import { ParallelReaderView, VIEW_TYPE_PARALLEL } from './src/view'; import { ParallelReaderView, VIEW_TYPE_PARALLEL } from './src/view';
@ -52,7 +61,7 @@ class ParallelReaderPlugin extends Plugin {
jobs!: GenerationJobManager; jobs!: GenerationJobManager;
activeBatch: BatchRunState | null = null; activeBatch: BatchRunState | null = null;
_scrollDispose: (() => void) | null = null; _scrollDispose: (() => void) | null = null;
_settingsSaveTimer: ReturnType<typeof setTimeout> | null = null; _settingsSaveTimer: number | null = null;
get cache(): Record<string, CacheEntry> { get cache(): Record<string, CacheEntry> {
return this.cacheManager.cache; return this.cacheManager.cache;
@ -88,12 +97,7 @@ class ParallelReaderPlugin extends Plugin {
this.addCommand({ this.addCommand({
id: 'open-view', id: 'open-view',
name: this.t('cmdOpenView'), name: this.t('cmdOpenView'),
callback: async () => { callback: () => this.toggleParallelView(),
const active = this.getActiveView();
const view = await this.ensureView();
if (!view) return;
if (active?.file) await this.syncViewToFile(active.file);
},
}); });
this.addCommand({ this.addCommand({
id: 'export-current', id: 'export-current',
@ -120,6 +124,7 @@ class ParallelReaderPlugin extends Plugin {
return; return;
} }
await this.cacheManager.delete(active.file.path); await this.cacheManager.delete(active.file.path);
this.refreshViewAfterCacheDelete(active.file.path);
new Notice(this.t('cacheClearedFile', { name: active.file.basename })); new Notice(this.t('cacheClearedFile', { name: active.file.basename }));
}, },
}); });
@ -129,6 +134,7 @@ class ParallelReaderPlugin extends Plugin {
callback: async () => { callback: async () => {
const n = Object.keys(this.cacheManager.cache).length; const n = Object.keys(this.cacheManager.cache).length;
await this.cacheManager.clear(); await this.cacheManager.clear();
this.refreshViewAfterCacheClear();
new Notice(this.t('cacheClearedAll', { count: n })); new Notice(this.t('cacheClearedAll', { count: n }));
}, },
}); });
@ -178,6 +184,7 @@ class ParallelReaderPlugin extends Plugin {
} }
onunload() { onunload() {
if (this.jobs) this.jobs.cancelAll();
this.flushSettingsSave().catch((e: unknown) => console.error('[parallel-reader] flush settings on unload', e)); this.flushSettingsSave().catch((e: unknown) => console.error('[parallel-reader] flush settings on unload', e));
this.flushCacheSave().catch((e: unknown) => console.error('[parallel-reader] flush cache on unload', e)); this.flushCacheSave().catch((e: unknown) => console.error('[parallel-reader] flush cache on unload', e));
} }
@ -199,15 +206,15 @@ class ParallelReaderPlugin extends Plugin {
async saveSettings() { async saveSettings() {
if (this._settingsSaveTimer) { if (this._settingsSaveTimer) {
clearTimeout(this._settingsSaveTimer); activeWindow.clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = null; this._settingsSaveTimer = null;
} }
await this.saveData({ settings: this.settings }); await this.saveData({ settings: this.settings });
} }
saveSettingsDebounced(delayMs = 400) { saveSettingsDebounced(delayMs = 400) {
if (this._settingsSaveTimer) clearTimeout(this._settingsSaveTimer); if (this._settingsSaveTimer) activeWindow.clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = setTimeout(() => { this._settingsSaveTimer = activeWindow.setTimeout(() => {
this._settingsSaveTimer = null; this._settingsSaveTimer = null;
this.saveSettings().catch((e: unknown) => console.error('[parallel-reader] failed to save settings', e)); this.saveSettings().catch((e: unknown) => console.error('[parallel-reader] failed to save settings', e));
}, delayMs); }, delayMs);
@ -215,7 +222,7 @@ class ParallelReaderPlugin extends Plugin {
async flushSettingsSave() { async flushSettingsSave() {
if (!this._settingsSaveTimer) return; if (!this._settingsSaveTimer) return;
clearTimeout(this._settingsSaveTimer); activeWindow.clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = null; this._settingsSaveTimer = null;
await this.saveSettings(); await this.saveSettings();
} }
@ -243,6 +250,33 @@ class ParallelReaderPlugin extends Plugin {
/* ---------- View management ---------- */ /* ---------- View management ---------- */
/**
* Open the panel if it does not exist yet; otherwise toggle the right
* sidebar's collapsed state. The leaf itself is preserved across toggles
* we never detach it, so the panel content survives a hide/show cycle.
*/
async toggleParallelView(): Promise<void> {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
if (leaves.length === 0) {
const active = this.getActiveView();
const view = await this.ensureView();
if (!view) return;
if (active?.file) await this.syncViewToFile(active.file);
return;
}
const rightSplit = this.app.workspace.rightSplit;
if (rightSplit?.collapsed) {
// Sidebar collapsed → expand and focus our tab.
await this.app.workspace.revealLeaf(leaves[0]);
} else if (rightSplit) {
// Sidebar expanded → collapse it. Tab state is preserved.
rightSplit.collapse();
} else {
// No right sidebar (mobile? unusual layout?) — just reveal.
await this.app.workspace.revealLeaf(leaves[0]);
}
}
async ensureView(): Promise<ParallelReaderView | null> { async ensureView(): Promise<ParallelReaderView | null> {
const { workspace } = this.app; const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0]; let leaf = workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0];
@ -328,7 +362,13 @@ class ParallelReaderPlugin extends Plugin {
} }
confirmRegenerateEditedCards(): Promise<boolean> { confirmRegenerateEditedCards(): Promise<boolean> {
return confirmRegenerateEditedCards(this.app, this.t('displayName'), this.t('confirmRegenerateEditedCards')); return confirmRegenerateEditedCards(
this.app,
this.t('displayName'),
this.t('confirmRegenerateEditedCards'),
this.t('confirmRegenerateCancel'),
this.t('confirmRegenerateProceed'),
);
} }
addFileMenuItems(menu: ObsidianMenu, file: unknown) { addFileMenuItems(menu: ObsidianMenu, file: unknown) {
@ -338,13 +378,13 @@ class ParallelReaderPlugin extends Plugin {
it it
.setTitle(this.t('fileMenuGenerate')) .setTitle(this.t('fileMenuGenerate'))
.setIcon('book-open') .setIcon('book-open')
.onClick(() => this.runForFile(file, false)), .onClick(() => void this.runForFile(file, false)),
); );
menu.addItem((it: ObsidianMenuItem) => menu.addItem((it: ObsidianMenuItem) =>
it it
.setTitle(this.t('fileMenuRegen')) .setTitle(this.t('fileMenuRegen'))
.setIcon('refresh-cw') .setIcon('refresh-cw')
.onClick(() => this.runForFile(file, true)), .onClick(() => void this.runForFile(file, true)),
); );
if (this.cacheManager.get(file.path)) { if (this.cacheManager.get(file.path)) {
menu.addItem((it: ObsidianMenuItem) => menu.addItem((it: ObsidianMenuItem) =>
@ -353,12 +393,23 @@ class ParallelReaderPlugin extends Plugin {
.setIcon('trash') .setIcon('trash')
.onClick(async () => { .onClick(async () => {
await this.cacheManager.delete(file.path); await this.cacheManager.delete(file.path);
this.refreshViewAfterCacheDelete(file.path);
new Notice(this.t('cacheClearedFile', { name: file.basename })); new Notice(this.t('cacheClearedFile', { name: file.basename }));
}), }),
); );
} }
} }
private refreshViewAfterCacheDelete(filePath: string) {
const view = this.getParallelView();
if (view?.sourceFile?.path === filePath) view.renderEmpty();
}
private refreshViewAfterCacheClear() {
const view = this.getParallelView();
if (view) view.renderEmpty();
}
async handleFileRename(file: TFile, oldPath: string) { async handleFileRename(file: TFile, oldPath: string) {
if (!(file instanceof TFile) || !oldPath) return; if (!(file instanceof TFile) || !oldPath) return;
const wasMarkdown = oldPath.endsWith('.md'); const wasMarkdown = oldPath.endsWith('.md');
@ -428,49 +479,74 @@ class ParallelReaderPlugin extends Plugin {
return this.runForFile(mdView.file, force); return this.runForFile(mdView.file, force);
} }
async runForFile(file: TFile | null, force: boolean) { async runForFile(
file: TFile | null,
force: boolean,
options: RunForFileOptions = {},
preloadedContent?: string,
): Promise<RunForFileResult> {
if (!file) { if (!file) {
new Notice(this.t('openNoteFirst')); new Notice(this.t('openNoteFirst'));
return; return 'error';
} }
if (this.jobs.isRunning(file.path)) { if (this.jobs.isPending(file.path)) {
new Notice(this.t('alreadyGenerating')); new Notice(this.t('alreadyGenerating'));
return; return 'already-running';
} }
if ( if (
!options.skipEditConfirm &&
shouldConfirmRegenerate(this.cacheManager.get(file.path), force) && shouldConfirmRegenerate(this.cacheManager.get(file.path), force) &&
!(await this.confirmRegenerateEditedCards()) !(await this.confirmRegenerateEditedCards())
) { ) {
new Notice(this.t('regenerateCancelled')); new Notice(this.t('regenerateCancelled'));
return; return 'cancelled';
} }
let view: ParallelReaderView | null = null; let view: ParallelReaderView | null = null;
return this.jobs let outcome: RunForFileResult = 'error';
await this.jobs
.start(file.path, async (job) => { .start(file.path, async (job) => {
job.setPhase('reading'); job.setPhase('reading');
const content = await this.app.vault.read(file); const content = preloadedContent ?? (await this.app.vault.read(file));
job.throwIfCancelled(); job.throwIfCancelled();
if (!content.trim()) { if (!content.trim()) {
new Notice(this.t('emptyNote')); new Notice(this.t('emptyNote'));
outcome = 'empty';
return; return;
} }
view = await this.ensureView(); if (options.silentView) {
if (!view) return; view = this.getParallelView() ?? null;
} else {
view = await this.ensureView();
if (!view) {
outcome = 'no-view';
return;
}
}
job.throwIfCancelled(); job.throwIfCancelled();
// In non-silent mode the user explicitly asked to generate THIS file —
// bind the view to it (so subsequent viewIsShowingFile guards pass and
// streaming/loadFor render correctly even if the panel was previously
// showing a different note).
const shouldRender = !options.silentView || this.viewIsShowingFile(view, file);
job.setPhase('cache-check'); job.setPhase('cache-check');
if (!force) { if (!force) {
const entry = this.cacheManager.get(file.path); const entry = this.cacheManager.get(file.path);
if (entry && cacheEntryMatches(entry, content, this.settings)) { if (entry && cacheEntryMatches(entry, content, this.settings)) {
this.cacheTouch(file.path); this.cacheTouch(file.path);
if (this.activeFileStillMatches(file)) view.loadFor(file, resolveCardAnchors(content, entry.cards), false); if (view && shouldRender && this.activeFileStillMatches(file)) {
view.loadFor(file, resolveCardAnchors(content, entry.cards), false);
}
outcome = 'cached';
return; return;
} }
} }
view.renderLoading(file, this.t('loadingGenerating')); if (view && shouldRender) view.renderLoading(file, this.t('loadingGenerating'));
const maxDocChars = Number(this.settings.maxDocChars) || DEFAULT_SETTINGS.maxDocChars; const maxDocChars = Number(this.settings.maxDocChars) || DEFAULT_SETTINGS.maxDocChars;
if (content.length > maxDocChars) new Notice(this.t('longNoteTruncated', { count: maxDocChars })); if (content.length > maxDocChars) new Notice(this.t('longNoteTruncated', { count: maxDocChars }));
new Notice(this.t('generatingNotice')); new Notice(this.t('generatingNotice'));
@ -479,14 +555,19 @@ class ParallelReaderPlugin extends Plugin {
const sections = await summarizeDocument(content, this.settings, job, this.streamProgressFor(view, file)); const sections = await summarizeDocument(content, this.settings, job, this.streamProgressFor(view, file));
job.throwIfCancelled(); job.throwIfCancelled();
if (sections.length === 0) { if (sections.length === 0) {
if (view && shouldRender) view.renderError(file, this.t('noCardsReturned'));
new Notice(this.t('noCardsReturned')); new Notice(this.t('noCardsReturned'));
outcome = 'empty';
return; return;
} }
const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets })); const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets }));
job.setPhase('saving'); job.setPhase('saving');
// Check BEFORE persisting: a job cancelled during the generate→disk window must not
// poison the cache (a later force=false run would otherwise serve the cancelled output).
job.throwIfCancelled();
await this.cacheManager.put(file.path, content, rawCards, this.settings); await this.cacheManager.put(file.path, content, rawCards, this.settings);
job.throwIfCancelled(); job.throwIfCancelled();
if (this.viewIsShowingFile(view, file)) view.loadFor(file, sections, false); if (view && shouldRender) view.loadFor(file, sections, false);
const unanchored = sections.filter((s) => s.startLine < 0).length; const unanchored = sections.filter((s) => s.startLine < 0).length;
new Notice( new Notice(
this.t('generationDone', { this.t('generationDone', {
@ -494,8 +575,17 @@ class ParallelReaderPlugin extends Plugin {
suffix: unanchored ? this.t('unanchoredSuffix', { count: unanchored }) : '', suffix: unanchored ? this.t('unanchoredSuffix', { count: unanchored }) : '',
}), }),
); );
outcome = 'generated';
}) })
.catch((e: unknown) => this.handleGenerationError(e, file, view)); .catch((e: unknown) => {
this.handleGenerationError(e, file, view, force);
if (e instanceof GenerationJobAlreadyRunningError) outcome = 'already-running';
else if (e instanceof GenerationJobCancelledError) outcome = 'cancelled';
else outcome = 'error';
if (options.rethrowErrors) throw e;
});
return outcome;
} }
private streamProgressFor( private streamProgressFor(
@ -510,9 +600,9 @@ class ParallelReaderPlugin extends Plugin {
}; };
} }
private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null) { private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null, force = false) {
if (e instanceof GenerationJobAlreadyRunningError) { if (e instanceof GenerationJobAlreadyRunningError) {
new Notice(e.message); new Notice(this.t('generationAlreadyRunning'));
return; return;
} }
if (e instanceof GenerationJobCancelledError) { if (e instanceof GenerationJobCancelledError) {
@ -524,7 +614,48 @@ class ParallelReaderPlugin extends Plugin {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
console.error(e); console.error(e);
if (view && this.viewIsShowingFile(view, file)) view.renderError(file, msg); if (view && this.viewIsShowingFile(view, file)) view.renderError(file, msg);
new Notice(this.t('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: msg })); showGenerationError(
{
app: this.app,
settings: this.settings,
openSettings: () => this.openSettings(),
onRetry: () => void this.runForFile(file, force),
},
kind,
e,
msg,
);
}
openSettings(): void {
// Obsidian doesn't expose a typed API for opening a specific plugin tab; use the documented
// (but technically internal) setting/openTabById path with safe fallbacks.
const setting = (this.app as unknown as { setting?: { open: () => void; openTabById: (id: string) => void } })
.setting;
if (!setting) return;
try {
setting.open();
setting.openTabById(this.manifest.id);
} catch (err: unknown) {
console.warn('[parallel-reader] failed to open settings tab', err);
}
}
/**
* True when the active backend has a usable credential: an API key (direct or via
* env var) for API backends, or any keyless/local provider (auth type 'none').
* CLI backends authenticate out-of-band (claude/codex login), so they are treated
* as configured. Used to show a first-run setup nudge instead of a dead-end.
*/
isCredentialConfigured(): boolean {
if (!isApiBackend(this.settings.backend)) return true;
try {
if (getApiAuthType(this.settings) === 'none') return true;
return !!getApiKey(this.settings);
} catch {
// Settings resolution can throw for half-configured custom providers — treat as not configured.
return false;
}
} }
async runBatchForFolder() { async runBatchForFolder() {
@ -532,7 +663,13 @@ class ParallelReaderPlugin extends Plugin {
new Notice(this.t('batchAlreadyRunning')); new Notice(this.t('batchAlreadyRunning'));
return; return;
} }
const folderPath = await promptForBatchFolder(this.app, this.t('batchSelectFolder'), this.t('batchFolderPrompt')); const folderPath = await promptForBatchFolder(
this.app,
this.t('batchSelectFolder'),
this.t('batchFolderPrompt'),
this.t('batchFolderConfirm'),
this.t('batchFolderCancel'),
);
if (folderPath === null) return; if (folderPath === null) return;
const validation = validateBatchFolderInput(folderPath, (path) => { const validation = validateBatchFolderInput(folderPath, (path) => {
const target = this.app.vault.getAbstractFileByPath(path); const target = this.app.vault.getAbstractFileByPath(path);
@ -565,14 +702,24 @@ class ParallelReaderPlugin extends Plugin {
continue; continue;
} }
try { try {
await this.runForFile(file, false); const result = await this.runForFile(
stats = recordBatchProcessed(stats); file,
false,
{ rethrowErrors: true, silentView: true, skipEditConfirm: true },
content,
);
if (batch.cancelled) break;
if (result === 'generated') stats = recordBatchProcessed(stats);
else if (result === 'cached' || result === 'already-running') stats = recordBatchSkip(stats);
else stats = recordBatchError(stats);
} catch (e: unknown) { } catch (e: unknown) {
if (batch.cancelled && e instanceof GenerationJobCancelledError) break;
stats = recordBatchError(stats); stats = recordBatchError(stats);
console.error('[parallel-reader] batch error for', file.path, e); console.error('[parallel-reader] batch error for', file.path, e);
} }
} }
} finally { } finally {
if (batch.cancelled) this.jobs.cancelAllWaiters();
if (this.activeBatch === batch) this.activeBatch = null; if (this.activeBatch === batch) this.activeBatch = null;
} }
const batchVars: Record<string, string | number> = { const batchVars: Record<string, string | number> = {
@ -637,7 +784,7 @@ class ParallelReaderPlugin extends Plugin {
try { try {
const pos = cm.posAtCoords({ x: rect.left + 20, y: topY }); const pos = cm.posAtCoords({ x: rect.left + 20, y: topY });
if (pos != null) topLine = cm.state.doc.lineAt(pos).number - 1; if (pos != null) topLine = cm.state.doc.lineAt(pos).number - 1;
} catch (_: unknown) { } catch {
/* posAtCoords/lineAt can throw during editor state transitions — safe to ignore */ /* posAtCoords/lineAt can throw during editor state transitions — safe to ignore */
return; return;
} }
@ -682,7 +829,7 @@ class ParallelReaderPlugin extends Plugin {
try { try {
mdView.editor.setCursor({ line, ch: 0 }); mdView.editor.setCursor({ line, ch: 0 });
mdView.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true); mdView.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true);
} catch (_: unknown) { } catch {
/* setCursor/scrollIntoView can throw during view transitions — safe to ignore */ /* setCursor/scrollIntoView can throw during view transitions — safe to ignore */
} }
} }
@ -690,4 +837,3 @@ class ParallelReaderPlugin extends Plugin {
} }
export default ParallelReaderPlugin; export default ParallelReaderPlugin;
export const __test = testExports;

View file

@ -1,10 +1,10 @@
{ {
"id": "parallel-reader", "id": "parallel-reader",
"name": "Parallel Reader", "name": "Parallel Reader",
"version": "1.0.7", "version": "1.0.24",
"minAppVersion": "1.4.0", "minAppVersion": "1.8.7",
"description": "AI-powered split-view reading: source note on the left, LLM-generated summary cards on the right with scroll-sync highlighting.", "description": "AI-powered split-view reading: source note on the left, LLM-generated summary cards on the right with scroll-sync highlighting.",
"author": "lancivez", "author": "lancivez",
"fundingUrl": "https://github.com/fancive", "fundingUrl": "https://github.com/fancive",
"isDesktopOnly": true "isDesktopOnly": true
} }

5297
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,18 +5,32 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"build": "node esbuild.config.mjs production", "build": "node esbuild.config.mjs production",
"check:build-artifacts": "node scripts/check-build-artifacts.mjs",
"dev": "node esbuild.config.mjs watch", "dev": "node esbuild.config.mjs watch",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "biome check main.ts src/", "coverage": "c8 node scripts/run-tests.mjs --all; status=$?; rm -rf .test-bundles; exit $status",
"lint:fix": "biome check --write main.ts src/", "lint": "biome check main.ts src/ tests/ scripts/ .e2e/scripts/",
"lint:fix": "biome check --write main.ts src/ tests/ scripts/ .e2e/scripts/",
"lint:obsidian": "eslint main.ts src/",
"lint:obsidian:review": "eslint --config eslint.obsidian-review.config.mjs --no-config-lookup main.ts src/",
"lint:obsidian:review:all": "npm run lint:obsidian:review -- --max-warnings=0",
"version": "node scripts/bump-version.mjs",
"test": "npm run build && npm run typecheck && node scripts/run-tests.mjs", "test": "npm run build && npm run typecheck && node scripts/run-tests.mjs",
"test:only": "node scripts/run-tests.mjs" "test:only": "node scripts/run-tests.mjs",
"test:unit": "node scripts/run-tests.mjs --category unit",
"test:component": "node scripts/run-tests.mjs --category component",
"test:contract": "node scripts/run-tests.mjs --category contract",
"e2e": "bash .e2e/gate.sh --json",
"test:e2e": "npm run build && node .e2e/cases/product-shell/run.mjs",
"test:live": "npm run build && node .e2e/cases/live/run.mjs"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.13", "@biomejs/biome": "^2.4.13",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@typescript-eslint/parser": "^8.59.1",
"c8": "^10.1.3",
"esbuild": "^0.24.2", "esbuild": "^0.24.2",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.9",
"obsidian": "^1.7.2", "obsidian": "^1.7.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }

48
scripts/bump-version.mjs Normal file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Bump plugin version in manifest.json and versions.json atomically.
* Usage: node scripts/bump-version.mjs <version>
* Example: node scripts/bump-version.mjs 1.0.8
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const version = process.argv[2];
const force = process.argv.includes('--force');
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
console.error('Usage: node scripts/bump-version.mjs <version> [--force]');
console.error('Example: node scripts/bump-version.mjs 1.0.8');
process.exit(1);
}
const manifestPath = path.join(root, 'manifest.json');
const versionsPath = path.join(root, 'versions.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const versions = JSON.parse(fs.readFileSync(versionsPath, 'utf8'));
if (versions[version] !== undefined && !force) {
console.error(`Version ${version} already exists in versions.json. Use --force to override.`);
process.exit(1);
}
const oldVersion = manifest.version;
manifest.version = version;
versions[version] = manifest.minAppVersion;
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, '\t') + '\n');
fs.writeFileSync(versionsPath, JSON.stringify(versions, null, '\t') + '\n');
// Stage files so npm version hook includes them in the auto-commit.
try {
execFileSync('git', ['add', manifestPath, versionsPath], { cwd: root });
} catch (_) {
// Not in a git repo or git not available — skip staging.
}
console.log(`Bumped version: ${oldVersion}${version}`);
console.log(`Updated: manifest.json, versions.json`);

View file

@ -1,19 +0,0 @@
import { execFileSync } from 'node:child_process';
function diffMainJs() {
return execFileSync('git', ['diff', '--', 'main.js'], { encoding: 'utf8' });
}
const before = diffMainJs();
execFileSync('npm', ['run', 'build'], { stdio: 'inherit' });
const after = diffMainJs();
if (after !== before) {
console.error('\nmain.js is not in sync with the TypeScript source.');
console.error('Run `npm run build` and include the updated main.js in the same change.');
process.exit(1);
}
console.log('main.js is in sync with the TypeScript source.');

View file

@ -1,24 +1,164 @@
import { readdirSync } from 'fs'; import { execFileSync } from 'child_process';
import { execSync } from 'child_process'; import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
const testsDir = join(import.meta.dirname, '..', 'tests'); const repoRoot = join(import.meta.dirname, '..');
const files = readdirSync(testsDir) const testsDir = join(repoRoot, 'tests');
const catalogPath = join(testsDir, 'catalog.json');
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
const allTestFiles = readdirSync(testsDir)
.filter((f) => f.endsWith('.test.js')) .filter((f) => f.endsWith('.test.js'))
.sort(); .sort();
let failed = 0; function usage() {
for (const file of files) { console.error(`Usage: node scripts/run-tests.mjs [--category <name> ...] [--all] [--list] [--json-output <path>]
const path = join(testsDir, file);
try { Categories: ${Object.keys(catalog.categories).join(', ')}
execSync(`node ${path}`, { stdio: 'inherit' }); Default: ${catalog.defaultCategories.join(', ')}`);
} catch { }
failed++;
function parseArgs(argv) {
const categories = [];
let all = false;
let list = false;
let jsonOutput = process.env.TEST_RESULTS_JSON || '';
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--all') {
all = true;
} else if (arg === '--list') {
list = true;
} else if (arg === '--category') {
const value = argv[++i];
if (!value) throw new Error('--category requires a value');
categories.push(value);
} else if (arg === '--json-output') {
const value = argv[++i];
if (!value) throw new Error('--json-output requires a path');
jsonOutput = value;
} else if (catalog.categories[arg]) {
categories.push(arg);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return {
categories: all ? Object.keys(catalog.categories) : categories.length ? categories : catalog.defaultCategories,
list,
jsonOutput,
};
}
function validateCatalog() {
const classified = new Map();
for (const [category, config] of Object.entries(catalog.categories)) {
if (!Array.isArray(config.files)) throw new Error(`catalog category ${category} must define files[]`);
for (const file of config.files) {
if (!file.endsWith('.test.js')) throw new Error(`catalog entry is not a test file: ${category}/${file}`);
if (classified.has(file)) throw new Error(`test file classified twice: ${file}`);
classified.set(file, category);
}
}
const missing = allTestFiles.filter((file) => !classified.has(file));
const extra = [...classified.keys()].filter((file) => !allTestFiles.includes(file));
if (missing.length || extra.length) {
throw new Error(
[
missing.length ? `unclassified test files: ${missing.join(', ')}` : '',
extra.length ? `catalog references missing files: ${extra.join(', ')}` : '',
]
.filter(Boolean)
.join('; '),
);
} }
} }
if (failed > 0) { function filesForCategories(categories) {
console.error(`\n${failed} test file(s) failed.`); const files = [];
for (const category of categories) {
const config = catalog.categories[category];
if (!config) throw new Error(`Unknown test category: ${category}`);
for (const file of config.files) files.push({ category, file });
}
return files;
}
function runTest(category, file) {
const filePath = join(testsDir, file);
const startedAt = Date.now();
let exitCode = 0;
try {
execFileSync('node', [filePath], { stdio: 'inherit' });
} catch (error) {
exitCode = typeof error.status === 'number' ? error.status : 1;
}
const durationMs = Date.now() - startedAt;
return {
category,
file,
status: exitCode === 0 ? 'passed' : 'failed',
exitCode,
durationMs,
};
}
function summarize(results, categories) {
const byCategory = {};
for (const category of categories) {
const categoryResults = results.filter((result) => result.category === category);
byCategory[category] = {
tests: categoryResults.length,
passed: categoryResults.filter((result) => result.status === 'passed').length,
failed: categoryResults.filter((result) => result.status === 'failed').length,
files: categoryResults.map((result) => result.file),
};
}
return {
catalogVersion: catalog.version,
categories,
total: results.length,
passed: results.filter((result) => result.status === 'passed').length,
failed: results.filter((result) => result.status === 'failed').length,
byCategory,
results,
};
}
function main() {
const options = parseArgs(process.argv.slice(2));
validateCatalog();
const selected = filesForCategories(options.categories);
if (options.list) {
for (const { category, file } of selected) console.log(`${category}\t${file}`);
return;
}
const missingFiles = selected.filter(({ file }) => !existsSync(join(testsDir, file))).map(({ file }) => file);
if (missingFiles.length) throw new Error(`Selected test files do not exist: ${missingFiles.join(', ')}`);
const results = selected.map(({ category, file }) => runTest(category, file));
const summary = summarize(results, options.categories);
if (options.jsonOutput) {
writeFileSync(join(repoRoot, options.jsonOutput), JSON.stringify(summary, null, 2) + '\n');
}
if (summary.failed > 0) {
console.error(`\n${summary.failed} test file(s) failed.`);
process.exit(1);
}
console.log(`\nAll ${summary.total} test files passed across ${options.categories.join(', ')}.`);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
usage();
process.exit(1); process.exit(1);
} }
console.log(`\nAll ${files.length} test files passed.`);

View file

@ -1,7 +1,33 @@
'use strict'; 'use strict';
export function findLineForAnchor(content: string, anchor: string): number { // Precompute the byte offset where each line starts (offsets[k] = start of
// line k). Built once per document so callers resolving many anchors over the
// same content avoid an O(n) newline rescan per anchor.
export function buildLineOffsets(content: string): number[] {
const offsets = [0];
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) offsets.push(i + 1);
}
return offsets;
}
// Largest line index whose start offset is <= charOffset, i.e. the 0-based
// line containing charOffset. Equivalent to counting '\n' before charOffset
// but O(log n) instead of O(n).
function offsetToLine(offsets: number[], charOffset: number): number {
let lo = 0;
let hi = offsets.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (offsets[mid] <= charOffset) lo = mid;
else hi = mid - 1;
}
return lo;
}
export function findLineForAnchor(content: string, anchor: string, lineOffsets?: number[]): number {
if (!anchor) return -1; if (!anchor) return -1;
const offsets = lineOffsets ?? buildLineOffsets(content);
const normalize = (s: string) => s.replace(/\s+/g, ' ').trim(); const normalize = (s: string) => s.replace(/\s+/g, ' ').trim();
const normalizeWithMap = (s: string) => { const normalizeWithMap = (s: string) => {
const chars: string[] = []; const chars: string[] = [];
@ -9,7 +35,18 @@ export function findLineForAnchor(content: string, anchor: string): number {
let pendingWhitespace = false; let pendingWhitespace = false;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
const c = s[i]; const c = s[i];
if (/\s/.test(c)) { const code = s.charCodeAt(i);
// ASCII whitespace fast path; defer to /\s/ only for rare non-ASCII
// code points so the exact RegExp semantics are preserved.
const isWhitespace =
code === 32 ||
code === 9 ||
code === 10 ||
code === 13 ||
code === 12 ||
code === 11 ||
(code > 127 && /\s/.test(c));
if (isWhitespace) {
pendingWhitespace = chars.length > 0; pendingWhitespace = chars.length > 0;
continue; continue;
} }
@ -27,9 +64,7 @@ export function findLineForAnchor(content: string, anchor: string): number {
if (!needle) return -1; if (!needle) return -1;
const idx = content.indexOf(needle); const idx = content.indexOf(needle);
if (idx === -1) return -1; if (idx === -1) return -1;
let line = 0; return offsetToLine(offsets, idx);
for (let i = 0; i < idx; i++) if (content[i] === '\n') line++;
return line;
}; };
let line = tryAt(anchor); let line = tryAt(anchor);
@ -51,7 +86,5 @@ export function findLineForAnchor(content: string, anchor: string): number {
if (normIdx === -1) return -1; if (normIdx === -1) return -1;
const originalIdx = normDoc.map[normIdx]; const originalIdx = normDoc.map[normIdx];
if (originalIdx == null) return -1; if (originalIdx == null) return -1;
let l = 0; return offsetToLine(offsets, originalIdx);
for (let j = 0; j < originalIdx; j++) if (content[j] === '\n') l++;
return l;
} }

60
src/backend-test.ts Normal file
View file

@ -0,0 +1,60 @@
'use strict';
import { requestUrl } from 'obsidian';
import { resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli';
import type { RequestUrlFunction } from './provider-request';
import { testApiBackend } from './providers';
import { normalizeCliTimeoutMs } from './settings';
import type { PluginSettings } from './types';
const CLI_TEST_SYSTEM =
'Return only valid JSON with exactly one card: {"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}';
const CLI_TEST_USER = 'parallel reader smoke anchor';
const CLI_TEST_TIMEOUT_MS = 60000;
type SpawnImpl = Parameters<typeof runCli>[5];
interface BackendTestDeps {
requestUrlImpl?: RequestUrlFunction;
spawnImpl?: SpawnImpl;
}
function cliSmokeSettings(settings: PluginSettings): PluginSettings {
return {
...settings,
cliTimeoutMs: Math.min(normalizeCliTimeoutMs(settings.cliTimeoutMs), CLI_TEST_TIMEOUT_MS),
minCards: 1,
maxCards: 1,
maxDocChars: 1000,
};
}
export async function testBackend(settings: PluginSettings, deps: BackendTestDeps = {}): Promise<string> {
if (settings.backend === 'claude-code') {
const cmd = resolveCliPath('claude', settings.cliPath);
const version = await runCli(cmd, ['--version'], '', 10000, undefined, deps.spawnImpl);
const cards = await summarizeViaClaudeCode(
CLI_TEST_SYSTEM,
CLI_TEST_USER,
cliSmokeSettings(settings),
undefined,
deps.spawnImpl,
);
return `claude @ ${cmd}\n${version.stdout.trim()}\nsmoke: ${cards.length} card`;
}
if (settings.backend === 'codex') {
const cmd = resolveCliPath('codex', settings.cliPath);
const version = await runCli(cmd, ['--version'], '', 10000, undefined, deps.spawnImpl);
const cards = await summarizeViaCodex(
CLI_TEST_SYSTEM,
CLI_TEST_USER,
cliSmokeSettings(settings),
undefined,
deps.spawnImpl,
);
return `codex @ ${cmd}\n${version.stdout.trim()}\nsmoke: ${cards.length} card`;
}
return testApiBackend(deps.requestUrlImpl || requestUrl, settings);
}

View file

@ -104,8 +104,20 @@ export function requestBatchCancel(state: BatchRunState | null): boolean {
return true; return true;
} }
export function promptForBatchFolder(app: App, selectText: string, promptText: string): Promise<string | null> { export function promptForBatchFolder(
app: App,
selectText: string,
promptText: string,
confirmText: string,
cancelText: string,
): Promise<string | null> {
return new Promise<string | null>((resolve) => { return new Promise<string | null>((resolve) => {
let settled = false;
const settle = (value: string | null) => {
if (settled) return;
settled = true;
resolve(value);
};
const modal = new Modal(app); const modal = new Modal(app);
modal.onOpen = () => { modal.onOpen = () => {
modal.contentEl.createEl('p', { text: selectText }); modal.contentEl.createEl('p', { text: selectText });
@ -114,16 +126,21 @@ export function promptForBatchFolder(app: App, selectText: string, promptText: s
input.placeholder = promptText; input.placeholder = promptText;
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
resolve(input.value.trim()); settle(input.value.trim());
modal.close(); modal.close();
} }
}); });
modal.contentEl.createEl('button', { text: 'OK' }).addEventListener('click', () => { const actions = modal.contentEl.createDiv({ cls: 'modal-button-container' });
resolve(input.value.trim()); actions.createEl('button', { text: cancelText }).addEventListener('click', () => {
settle(null);
modal.close();
});
actions.createEl('button', { text: confirmText, cls: 'mod-cta' }).addEventListener('click', () => {
settle(input.value.trim());
modal.close(); modal.close();
}); });
}; };
modal.onClose = () => resolve(null); modal.onClose = () => settle(null);
modal.open(); modal.open();
}); });
} }

View file

@ -11,9 +11,30 @@ import {
} from './settings'; } from './settings';
import type { CacheEntry, PluginSettings, RawCard, ResolvedCard } from './types'; import type { CacheEntry, PluginSettings, RawCard, ResolvedCard } from './types';
/**
* Reject cache entries whose shape would crash downstream consumers (e.g. cards.map).
* Missing optional fields like contentHash/settingsHash are tolerated they just
* cause a normal cache miss instead of returning stale data.
*/
function isValidCacheEntry(entry: unknown): entry is CacheEntry {
if (!entry || typeof entry !== 'object') return false;
const cards = (entry as { cards?: unknown }).cards;
if (!Array.isArray(cards)) return false;
for (const c of cards) {
if (!c || typeof c !== 'object') return false;
const card = c as { bullets?: unknown; anchor?: unknown };
// bullets is allowed to be missing (defaults to []), but if present must be an array
if (card.bullets !== undefined && !Array.isArray(card.bullets)) return false;
// anchor is allowed to be missing, but if present must be a string
// (downstream resolveCardAnchors / findLineForAnchor expects string)
if (card.anchor !== undefined && typeof card.anchor !== 'string') return false;
}
return true;
}
export class CacheManager { export class CacheManager {
cache: Record<string, CacheEntry> = {}; cache: Record<string, CacheEntry> = {};
private _timer: ReturnType<typeof setTimeout> | null = null; private _timer: number | null = null;
private _dirty = false; private _dirty = false;
constructor( constructor(
@ -32,7 +53,7 @@ export class CacheManager {
try { try {
if (typeof this.adapter.exists === 'function' && (await this.adapter.exists(dir))) return; if (typeof this.adapter.exists === 'function' && (await this.adapter.exists(dir))) return;
await this.adapter.mkdir(dir); await this.adapter.mkdir(dir);
} catch (_) { } catch {
/* ignore race */ /* ignore race */
} }
} }
@ -41,8 +62,16 @@ export class CacheManager {
try { try {
const raw = await this.adapter.read(this.filePath()); const raw = await this.adapter.read(this.filePath());
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
return parsed.entries; const validated: Record<string, CacheEntry> = {};
let dropped = 0;
for (const [path, entry] of Object.entries(parsed.entries as Record<string, unknown>)) {
if (isValidCacheEntry(entry)) validated[path] = entry;
else dropped++;
}
if (dropped > 0) console.warn('[parallel-reader] dropped', dropped, 'malformed cache entries');
return validated;
}
} catch (e: unknown) { } catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e); const message = e instanceof Error ? e.message : String(e);
if (!/not found|does not exist|ENOENT/i.test(message)) if (!/not found|does not exist|ENOENT/i.test(message))
@ -75,7 +104,7 @@ export class CacheManager {
async save(): Promise<void> { async save(): Promise<void> {
if (this._timer) { if (this._timer) {
clearTimeout(this._timer); activeWindow.clearTimeout(this._timer);
this._timer = null; this._timer = null;
} }
this.prune(); this.prune();
@ -86,7 +115,7 @@ export class CacheManager {
scheduleSave(delayMs = 5000): void { scheduleSave(delayMs = 5000): void {
this._dirty = true; this._dirty = true;
if (this._timer) return; if (this._timer) return;
this._timer = setTimeout(() => { this._timer = activeWindow.setTimeout(() => {
this._timer = null; this._timer = null;
if (!this._dirty) return; if (!this._dirty) return;
this.save().catch((e: unknown) => console.error('[parallel-reader] failed to save cache', e)); this.save().catch((e: unknown) => console.error('[parallel-reader] failed to save cache', e));
@ -95,7 +124,7 @@ export class CacheManager {
async flush(): Promise<void> { async flush(): Promise<void> {
if (this._timer) { if (this._timer) {
clearTimeout(this._timer); activeWindow.clearTimeout(this._timer);
this._timer = null; this._timer = null;
} }
if (!this._dirty) return; if (!this._dirty) return;

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
import { findLineForAnchor } from './anchor'; import { buildLineOffsets, findLineForAnchor } from './anchor';
import type { CardPatch, RawCard, ResolvedCard } from './types'; import type { CardPatch, RawCard, ResolvedCard } from './types';
export function removeCardAt<T extends RawCard>(cards: T[], index: number): T[] { export function removeCardAt<T extends RawCard>(cards: T[], index: number): T[] {
@ -29,12 +29,15 @@ export function updateCardAt<T extends RawCard>(cards: T[], index: number, patch
} }
export function resolveCardAnchors(content: string, rawCards: RawCard[]): ResolvedCard[] { export function resolveCardAnchors(content: string, rawCards: RawCard[]): ResolvedCard[] {
// Build the line-offset index once and reuse it for every card instead of
// rescanning the whole document per anchor.
const lineOffsets = buildLineOffsets(content);
const resolved: ResolvedCard[] = (rawCards || []).map((c: RawCard) => ({ const resolved: ResolvedCard[] = (rawCards || []).map((c: RawCard) => ({
title: c.title, title: c.title,
level: 2, level: 2,
anchor: c.anchor, anchor: c.anchor,
gist: c.gist, gist: c.gist,
startLine: findLineForAnchor(content, c.anchor), startLine: findLineForAnchor(content, c.anchor, lineOffsets),
bullets: c.bullets || [], bullets: c.bullets || [],
})); }));
resolved.sort((a, b) => { resolved.sort((a, b) => {

View file

@ -5,6 +5,7 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { type GenerationJob, GenerationJobCancelledError } from './generation-job-manager'; import { type GenerationJob, GenerationJobCancelledError } from './generation-job-manager';
import { translate } from './i18n';
import { parseCardsJson } from './schema'; import { parseCardsJson } from './schema';
import type { PluginSettings, RawCard } from './types'; import type { PluginSettings, RawCard } from './types';
@ -43,7 +44,7 @@ export function resolveCliPath(name: string, override: string): string {
const p = path.join(dir, name + ext); const p = path.join(dir, name + ext);
try { try {
if (fs.existsSync(p)) return p; if (fs.existsSync(p)) return p;
} catch (_) { } catch {
// Ignore unreadable candidate paths and keep searching. // Ignore unreadable candidate paths and keep searching.
} }
} }
@ -51,6 +52,90 @@ export function resolveCliPath(name: string, override: string): string {
return name; return name;
} }
type CliFailureReason = 'wall-timeout' | 'exit-nonzero' | 'streams-unavailable' | 'spawn-failure' | 'startup-error';
export interface CliErrorDetails {
reason: CliFailureReason;
cmd: string;
pid: number | null;
elapsedMs: number;
/** ms since the last byte of stdout/stderr — useful to tell hung-mid-call from hung-from-start. */
idleMs: number | null;
stdoutBytes: number;
stderrBytes: number;
/** Already secret-redacted, capped to DIAG_STDERR_TAIL_CHARS. */
stderrTail: string;
/** Already secret-redacted, capped to DIAG_STDOUT_TAIL_CHARS. */
stdoutTail: string;
exitCode: number | null;
signal: NodeJS.Signals | null;
timeoutMs: number;
}
export class CliProcessError extends Error {
readonly name = 'CliProcessError';
readonly details: CliErrorDetails;
constructor(message: string, details: CliErrorDetails) {
super(message);
this.details = details;
}
}
const DIAG_STDERR_TAIL_CHARS = 1500;
const DIAG_STDOUT_TAIL_CHARS = 1500;
const EMPTY_MCP_CONFIG = '{"mcpServers":{}}';
function tail(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(-max);
}
const REDACT = '[REDACTED]';
function redactSecrets(text: string): string {
if (!text) return text;
return text
.replace(/\b(Bearer)\s+[\w.\-+/=]{6,}/gi, `$1 ${REDACT}`)
.replace(/\b(x-api-key|x-goog-api-key|api[_-]?key|authorization)\s*[:=]\s*[\w.\-+/=]{6,}/gi, `$1: ${REDACT}`)
.replace(/\b(sk-[A-Za-z0-9_-]{12,})/g, REDACT);
}
function utf8ByteLength(text: string): number {
return Buffer.byteLength(text, 'utf8');
}
function claudeResultText(stdout: string): string {
const events: Array<Record<string, unknown>> = [];
try {
const parsed = JSON.parse(stdout);
if (Array.isArray(parsed)) {
events.push(...(parsed.filter((e) => e && typeof e === 'object') as Array<Record<string, unknown>>));
} else if (parsed && typeof parsed === 'object') {
events.push(parsed as Record<string, unknown>);
}
} catch {
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') events.push(parsed as Record<string, unknown>);
} catch {
// Ignore non-JSON progress lines and keep scanning for result events.
}
}
}
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
if (event.type === 'result' && typeof event.result === 'string') return event.result;
}
const single = events[0];
if (single && typeof single.result === 'string') return single.result;
if (single && typeof single.content === 'string') return single.content;
return '';
}
export function runCli( export function runCli(
cmd: string, cmd: string,
args: string[], args: string[],
@ -62,21 +147,48 @@ export function runCli(
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
let settled = false; let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: number | null = null;
const startedAt = Date.now();
let lastActivityAt = startedAt;
const clearTimers = () => {
if (timer) {
activeWindow.clearTimeout(timer);
timer = null;
}
};
const fail = (err: Error) => { const fail = (err: Error) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (timer) clearTimeout(timer); clearTimers();
console.warn('[parallel-reader] cli failed', {
cmd,
pid: child?.pid,
elapsedMs: Date.now() - startedAt,
error: err.message,
});
reject(err); reject(err);
}; };
const succeed = (value: { stdout: string; stderr: string }) => { const succeed = (value: { stdout: string; stderr: string }) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (timer) clearTimeout(timer); clearTimers();
console.debug('[parallel-reader] cli ok', {
cmd,
pid: child?.pid,
elapsedMs: Date.now() - startedAt,
stdoutBytes: utf8ByteLength(value.stdout),
stderrBytes: utf8ByteLength(value.stderr),
});
resolve(value); resolve(value);
}; };
try { try {
child = spawnImpl(cmd, args, { child = spawnImpl(cmd, args, {
// Lock cwd to the user's home dir. Inheriting Obsidian's cwd (often the Vault path)
// can break Claude CLI's per-project memory writes — iCloud-synced Vault paths
// contain characters like `~md~` that produce malformed `~/.claude/projects/...`
// slugs and an immediate exit-1 with empty modelUsage. Home dir is stable and
// writable; the LLM call itself is independent of cwd.
cwd: os.homedir(),
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
@ -92,19 +204,75 @@ export function runCli(
}, },
}); });
} catch (e: unknown) { } catch (e: unknown) {
return reject(new Error(`Failed to start ${cmd}: ${e instanceof Error ? e.message : String(e)}`)); const message = `Failed to start ${cmd}: ${e instanceof Error ? e.message : String(e)}`;
console.warn('[parallel-reader] cli spawn failed', { cmd, error: message });
return reject(
new CliProcessError(message, {
reason: 'spawn-failure',
cmd,
pid: null,
elapsedMs: Date.now() - startedAt,
idleMs: null,
stdoutBytes: 0,
stderrBytes: 0,
stdoutTail: '',
stderrTail: '',
exitCode: null,
signal: null,
timeoutMs,
}),
);
} }
console.debug('[parallel-reader] cli spawn', {
cmd,
argCount: args.length,
pid: child?.pid,
timeoutMs,
});
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
timer = setTimeout(() => {
const collectDetails = (
reason: CliFailureReason,
idleMs: number,
extras: { exitCode?: number | null; signal?: NodeJS.Signals | null } = {},
): CliErrorDetails => ({
reason,
cmd,
pid: child?.pid ?? null,
elapsedMs: Date.now() - startedAt,
idleMs,
stdoutBytes: utf8ByteLength(stdout),
stderrBytes: utf8ByteLength(stderr),
stdoutTail: redactSecrets(tail(stdout, DIAG_STDOUT_TAIL_CHARS)),
stderrTail: redactSecrets(tail(stderr, DIAG_STDERR_TAIL_CHARS)),
exitCode: extras.exitCode ?? null,
signal: extras.signal ?? null,
timeoutMs,
});
const buildDiagSuffix = (details: CliErrorDetails): string => {
let suffix =
` [pid=${details.pid ?? 'unknown'} elapsed=${details.elapsedMs}ms idle=${details.idleMs ?? 0}ms ` +
`stdout=${details.stdoutBytes}B stderr=${details.stderrBytes}B]`;
if (details.stderrTail) suffix += `\n--- stderr tail ---\n${details.stderrTail}`;
if (details.stdoutTail) suffix += `\n--- stdout tail ---\n${details.stdoutTail}`;
return suffix;
};
timer = activeWindow.setTimeout(() => {
try { try {
child.kill('SIGKILL'); child.kill('SIGKILL');
} catch (e: unknown) { } catch (e: unknown) {
console.warn('[parallel-reader] failed to kill timed-out CLI process', e); console.warn('[parallel-reader] failed to kill timed-out CLI process', e);
} }
fail(new Error(`CLI timed out (${timeoutMs}ms)`)); const idleMs = Date.now() - lastActivityAt;
const details = collectDetails('wall-timeout', idleMs, { signal: 'SIGKILL' });
fail(new CliProcessError(`CLI timed out (${timeoutMs}ms)${buildDiagSuffix(details)}`, details));
}, timeoutMs); }, timeoutMs);
if (job) { if (job) {
job.onCancel(() => { job.onCancel(() => {
try { try {
@ -117,7 +285,7 @@ export function runCli(
} }
if (!child.stdout || !child.stderr || !child.stdin) { if (!child.stdout || !child.stderr || !child.stdin) {
fail(new Error('CLI process streams are unavailable')); fail(new CliProcessError('CLI process streams are unavailable', collectDetails('streams-unavailable', 0)));
return; return;
} }
@ -125,19 +293,41 @@ export function runCli(
const childStderr = child.stderr; const childStderr = child.stderr;
const childStdin = child.stdin; const childStdin = child.stdin;
const noteActivity = () => {
if (settled) return;
lastActivityAt = Date.now();
};
childStdout.on('data', (d) => { childStdout.on('data', (d) => {
stdout += d.toString('utf8'); stdout += d.toString('utf8');
noteActivity();
}); });
childStderr.on('data', (d) => { childStderr.on('data', (d) => {
stderr += d.toString('utf8'); stderr += d.toString('utf8');
noteActivity();
}); });
child.on('error', (e) => { child.on('error', (e) => {
fail(new Error(`CLI startup error: ${e.message}. Try setting an absolute CLI path.`)); const idleMs = Date.now() - lastActivityAt;
fail(
new CliProcessError(
`CLI startup error: ${e.message}. Try setting an absolute CLI path.`,
collectDetails('startup-error', idleMs),
),
);
}); });
child.on('close', (code) => { child.on('close', (code, signal) => {
if (settled) return; if (settled) return;
if (code !== 0) { if (code !== 0) {
return fail(new Error(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`)); const idleMs = Date.now() - lastActivityAt;
const details = collectDetails('exit-nonzero', idleMs, { exitCode: code, signal });
// Some CLIs (notably `claude --output-format json`) emit error events to stdout
// instead of stderr. Always include both tails so the failure mode is debuggable
// even when stderr is empty.
let suffix = '';
if (details.stderrTail) suffix += `\nstderr:\n${details.stderrTail}`;
if (details.stdoutTail) suffix += `\nstdout:\n${details.stdoutTail}`;
if (!suffix) suffix = '\n(no output on either stream)';
return fail(new CliProcessError(`CLI exited with code ${code} (signal=${signal ?? 'none'})${suffix}`, details));
} }
succeed({ stdout, stderr }); succeed({ stdout, stderr });
}); });
@ -146,7 +336,7 @@ export function runCli(
try { try {
childStdin.write(stdinText); childStdin.write(stdinText);
childStdin.end(); childStdin.end();
} catch (_e) { } catch {
// Child may have exited before stdin was written. // Child may have exited before stdin was written.
} }
} else { } else {
@ -170,35 +360,43 @@ export async function summarizeViaClaudeCode(
const args = [ const args = [
'-p', '-p',
'--output-format', '--output-format',
'json', 'stream-json',
'--verbose',
'--append-system-prompt', '--append-system-prompt',
system, system,
'--tools',
'',
'--disallowed-tools', '--disallowed-tools',
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task', 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,LSP,NotebookEdit',
'--no-session-persistence',
'--disable-slash-commands',
'--no-chrome',
'--strict-mcp-config',
'--mcp-config',
EMPTY_MCP_CONFIG,
]; ];
const claudeModel = (settings.model || '').trim();
if (claudeModel) args.push('--model', claudeModel);
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job, spawnImpl); const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job, spawnImpl);
// --output-format json produces a JSON array of event objects. const resultText = claudeResultText(stdout);
// Find the "result" entry and extract its .result text field. if (!resultText && stdout.trim()) {
let resultText = ''; console.warn(
try { '[parallel-reader] claude CLI returned unexpected output. length=',
const events = JSON.parse(stdout); stdout.length,
if (Array.isArray(events)) { 'head=',
const resultEvent = events.find((e: Record<string, unknown>) => e.type === 'result'); stdout.slice(0, 80),
if (resultEvent && typeof resultEvent.result === 'string') { );
resultText = resultEvent.result;
}
} else if (events && typeof events === 'object') {
// Older CLI versions return a single object
resultText = events.result || events.content || '';
}
} catch (_) {
throw new Error('claude CLI returned unexpected output:\n' + stdout.slice(0, 500));
} }
if (!resultText) { if (!resultText) {
console.warn('[parallel-reader] claude CLI returned no result. Full stdout:', stdout); console.warn(
throw new Error('claude CLI returned no result. Output:\n' + stdout.slice(0, 500)); '[parallel-reader] claude CLI returned no result. length=',
stdout.length,
'head=',
stdout.slice(0, 80),
);
throw new Error(translate(settings, 'errorClaudeCliNoResult', { length: String(stdout.length) }));
} }
return parseCardsJson(resultText, settings); return parseCardsJson(resultText, settings);
@ -213,7 +411,10 @@ export async function summarizeViaCodex(
): Promise<RawCard[]> { ): Promise<RawCard[]> {
const cmd = resolveCliPath('codex', settings.cliPath); const cmd = resolveCliPath('codex', settings.cliPath);
const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\nOutput JSON directly with no explanation.`; const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\nOutput JSON directly with no explanation.`;
const args = ['exec', '--skip-git-repo-check', '-']; // NOTE: do NOT pass --model. settings.model defaults to a Claude model name
// (claude-sonnet-4-6), which would break Codex if passed verbatim. Codex
// uses its own config.toml profile for model selection.
const args = ['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '-'];
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job, spawnImpl); const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job, spawnImpl);
return parseCardsJson(stdout, settings); return parseCardsJson(stdout, settings);
} }

242
src/error-ui.ts Normal file
View file

@ -0,0 +1,242 @@
'use strict';
import { type App, Modal, Notice, Setting } from 'obsidian';
import type { CliErrorDetails } from './cli';
import { translate } from './i18n';
import type { ErrorKind, PluginSettings } from './types';
import { copyToClipboard } from './ui-helpers';
interface GenerationErrorContext {
app: App;
settings: PluginSettings;
/** Open the plugin settings tab (best-effort; falls back to no-op if unavailable). */
openSettings: () => void;
/** Re-run the generation that failed (used by retryable error kinds such as network). */
onRetry?: () => void;
}
interface ActionableNoticeAction {
label: string;
primary?: boolean;
onClick: () => void;
}
function showActionableNotice(message: string, actions: ActionableNoticeAction[], durationMs = 12000): Notice {
const notice = new Notice('', durationMs);
const root = notice.messageEl;
root.addClass('parallel-reader-error-notice');
root.createDiv({ cls: 'parallel-reader-error-notice-message', text: message });
if (actions.length > 0) {
const buttonRow = root.createDiv({ cls: 'parallel-reader-error-notice-actions' });
for (const action of actions) {
const btn = buttonRow.createEl('button', {
cls: action.primary ? 'parallel-reader-error-notice-button mod-cta' : 'parallel-reader-error-notice-button',
text: action.label,
attr: { type: 'button' },
});
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
try {
action.onClick();
} catch (err: unknown) {
console.error('[parallel-reader] error notice action failed', err);
} finally {
notice.hide();
}
});
}
}
return notice;
}
function reasonCopyKeys(reason: CliErrorDetails['reason']): { titleKey: string; reasonKey: string } {
switch (reason) {
case 'wall-timeout':
return { titleKey: 'errorModalTimeoutTitle', reasonKey: 'errorModalReasonWall' };
case 'exit-nonzero':
return { titleKey: 'errorModalExitTitle', reasonKey: 'errorModalReasonExit' };
case 'spawn-failure':
case 'startup-error':
return { titleKey: 'errorModalStartupTitle', reasonKey: 'errorModalReasonStartup' };
default:
return { titleKey: 'errorModalTimeoutTitle', reasonKey: 'errorModalReasonWall' };
}
}
class CliDiagnosticsModal extends Modal {
private readonly settings: PluginSettings;
private readonly details: CliErrorDetails;
private readonly fullMessage: string;
private readonly onOpenSettings: () => void;
constructor(
app: App,
settings: PluginSettings,
details: CliErrorDetails,
fullMessage: string,
onOpenSettings: () => void,
) {
super(app);
this.settings = settings;
this.details = details;
this.fullMessage = fullMessage;
this.onOpenSettings = onOpenSettings;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('parallel-reader-error-modal');
const { titleKey, reasonKey } = reasonCopyKeys(this.details.reason);
contentEl.createEl('h2', { text: translate(this.settings, titleKey) });
contentEl.createEl('p', { text: translate(this.settings, reasonKey) });
if (this.details.exitCode != null) {
contentEl.createEl('p', {
cls: 'parallel-reader-error-modal-exit',
text: translate(this.settings, 'errorModalFieldExit', {
code: String(this.details.exitCode),
signal: this.details.signal ?? 'none',
}),
});
}
const grid = contentEl.createDiv({ cls: 'parallel-reader-error-modal-grid' });
const addRow = (label: string, value: string) => {
const row = grid.createDiv({ cls: 'parallel-reader-error-modal-row' });
row.createSpan({ cls: 'parallel-reader-error-modal-label', text: label });
row.createSpan({ cls: 'parallel-reader-error-modal-value', text: value });
};
addRow(translate(this.settings, 'errorModalFieldCmd'), this.details.cmd);
addRow(translate(this.settings, 'errorModalFieldPid'), String(this.details.pid ?? '—'));
addRow(translate(this.settings, 'errorModalFieldElapsed'), `${this.details.elapsedMs}ms`);
addRow(
translate(this.settings, 'errorModalFieldIdle'),
this.details.idleMs == null ? '—' : `${this.details.idleMs}ms`,
);
addRow(
translate(this.settings, 'errorModalFieldBytes'),
`stdout ${this.details.stdoutBytes}B / stderr ${this.details.stderrBytes}B`,
);
let hasOutput = false;
if (this.details.stderrTail) {
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStderrTail') });
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stderrTail });
hasOutput = true;
}
if (this.details.stdoutTail) {
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStdoutTail') });
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stdoutTail });
hasOutput = true;
}
if (!hasOutput) {
contentEl.createEl('p', {
cls: 'parallel-reader-error-modal-empty',
text: translate(this.settings, 'errorModalNoOutput'),
});
}
new Setting(contentEl)
.addButton((b) =>
b.setButtonText(translate(this.settings, 'errorModalActionCopy')).onClick(() => {
void copyToClipboard(this.fullMessage, translate(this.settings, 'errorModalCopySuccess'));
}),
)
.addButton((b) =>
b
.setButtonText(translate(this.settings, 'errorModalActionOpenSettings'))
.setCta()
.onClick(() => {
this.onOpenSettings();
this.close();
}),
)
.addButton((b) => b.setButtonText(translate(this.settings, 'errorModalActionClose')).onClick(() => this.close()));
}
onClose(): void {
this.contentEl.empty();
}
}
export function showGenerationError(
ctx: GenerationErrorContext,
kind: ErrorKind,
error: unknown,
message: string,
): void {
const tr = (k: string, vars?: Record<string, string | number>) => translate(ctx.settings, k, vars);
// Any structured CLI error opens the diagnostics modal — even when classify falls
// through to 'unknown' (e.g. exit-nonzero with empty stderr) we want the stdout tail
// surfaced so the failure is debuggable.
const cliDetails = (error as { details?: CliErrorDetails }).details;
const isStructuredCliError = !!cliDetails && typeof cliDetails.reason === 'string';
if (isStructuredCliError) {
new CliDiagnosticsModal(ctx.app, ctx.settings, cliDetails, message, ctx.openSettings).open();
return;
}
if (kind === 'timeout') {
// API streaming timeout: no structured details, show actionable notice with raw message tail.
showActionableNotice(tr('errorNoticeTimeout'), [
{
label: tr('errorActionCopyDetails'),
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
},
{ label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings },
]);
return;
}
if (kind === 'auth') {
showActionableNotice(tr('errorNoticeAuth'), [
{ label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings },
]);
return;
}
if (kind === 'rate-limit') {
showActionableNotice(tr('errorNoticeRateLimit', { error: message }), [
{
label: tr('errorActionCopyDetails'),
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
},
]);
return;
}
if (kind === 'network') {
const actions: ActionableNoticeAction[] = [];
if (ctx.onRetry) actions.push({ label: tr('errorActionRetry'), primary: true, onClick: ctx.onRetry });
actions.push({
label: tr('errorActionCopyDetails'),
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
});
showActionableNotice(tr('errorNoticeNetwork'), actions);
return;
}
if (kind === 'schema') {
showActionableNotice(tr('errorNoticeSchema'), [
{
label: tr('errorActionCopyRaw'),
primary: true,
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
},
{ label: tr('errorActionOpenSettings'), onClick: ctx.openSettings },
]);
return;
}
if (kind === 'config') {
showActionableNotice(tr('errorNoticeConfig', { error: message }), [
{ label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings },
]);
return;
}
// Unknown / fallback: keep the legacy short Notice with kind tag, no buttons.
new Notice(tr('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: message }));
}

View file

@ -7,7 +7,7 @@ export class GenerationJobAlreadyRunningError extends Error {
key: string; key: string;
constructor(key: string) { constructor(key: string) {
super('该笔记正在生成对照笔记'); super('already-running');
this.name = 'GenerationJobAlreadyRunningError'; this.name = 'GenerationJobAlreadyRunningError';
this.code = 'already-running'; this.code = 'already-running';
this.key = key; this.key = key;
@ -19,7 +19,7 @@ export class GenerationJobCancelledError extends Error {
key: string; key: string;
constructor(key: string) { constructor(key: string) {
super('生成已取消'); super('cancelled');
this.name = 'GenerationJobCancelledError'; this.name = 'GenerationJobCancelledError';
this.code = 'cancelled'; this.code = 'cancelled';
this.key = key; this.key = key;
@ -76,10 +76,20 @@ export class GenerationJob {
} }
} }
type Waiter = { key: string; resolve: () => void; reject: (err: Error) => void };
export class GenerationJobManager { export class GenerationJobManager {
private jobs: Map<string, GenerationJob>; private jobs: Map<string, GenerationJob>;
private waiters: Waiter[] = [];
/**
* Live count of slots in use OR resolved-but-not-yet-set. This is the
* authoritative concurrency gauge using `jobs.size` alone has a race
* window between `releaseSlot.resolve()` and the awaiter's `jobs.set()`
* during which a fresh `start()` could see a free slot and double-book.
*/
private reserved = 0;
constructor() { constructor(private maxConcurrent: number = 3) {
this.jobs = new Map(); this.jobs = new Map();
} }
@ -91,8 +101,71 @@ export class GenerationJobManager {
return this.jobs.has(key); return this.jobs.has(key);
} }
/** Returns true if a key is either running or queued. */
isPending(key: string): boolean {
return this.jobs.has(key) || this.waiters.some((w) => w.key === key);
}
waitingCount(): number {
return this.waiters.length;
}
/**
* Reject all queued waiters with cancellation. Used on plugin unload or batch cancel
* so queued promises don't leak. Does not affect `reserved` queued waiters were
* not yet counted toward `reserved` (they get incremented on resolve).
*/
cancelAllWaiters(): number {
const drained = this.waiters.splice(0);
for (const w of drained) w.reject(new GenerationJobCancelledError(w.key));
return drained.length;
}
/**
* Returns null if a slot is immediately available (and reserves it synchronously,
* so the caller can take the fast path). Returns a Promise when queueing is required.
*/
private waitSlot(key: string): Promise<void> | null {
if (this.reserved < this.maxConcurrent) {
this.reserved++;
return null;
}
return new Promise<void>((resolve, reject) => {
// Wrap resolve so the slot is reserved synchronously inside releaseSlot()
// before any other start() can read `reserved`.
this.waiters.push({
key,
resolve: () => {
this.reserved++;
resolve();
},
reject,
});
});
}
private releaseSlot(): void {
this.reserved--;
const next = this.waiters.shift();
if (next) next.resolve(); // wrapped resolve increments `reserved` synchronously
}
async start<T>(key: string, runner: (job: GenerationJob) => Promise<T>): Promise<T> { async start<T>(key: string, runner: (job: GenerationJob) => Promise<T>): Promise<T> {
if (this.jobs.has(key)) throw new GenerationJobAlreadyRunningError(key); // Reject same-key dedup at entry (running OR queued).
if (this.isPending(key)) throw new GenerationJobAlreadyRunningError(key);
const wait = this.waitSlot(key);
if (wait) {
// If cancelled while queued (cancelAllWaiters), the slot was never reserved
// for this caller, so no releaseSlot is needed — propagation is enough.
await wait;
if (this.jobs.has(key)) {
// Same-key racily inserted while we waited; release the slot we got.
this.reserved--;
const next = this.waiters.shift();
if (next) next.resolve();
throw new GenerationJobAlreadyRunningError(key);
}
}
const job = new GenerationJob(key); const job = new GenerationJob(key);
this.jobs.set(key, job); this.jobs.set(key, job);
try { try {
@ -107,6 +180,7 @@ export class GenerationJobManager {
throw err; throw err;
} finally { } finally {
this.jobs.delete(key); this.jobs.delete(key);
this.releaseSlot();
} }
} }
@ -115,17 +189,57 @@ export class GenerationJobManager {
if (!job) return false; if (!job) return false;
return job.cancel(); return job.cancel();
} }
/**
* Cancel every in-flight job firing each job's cancel handlers, which abort the
* streaming HTTP request and SIGKILL any CLI child process and reject all queued
* waiters. Used on plugin unload so nothing keeps running after teardown. Returns
* the total number of jobs and waiters cancelled.
*/
cancelAll(): number {
let cancelled = 0;
for (const job of this.jobs.values()) {
if (job.cancel()) cancelled++;
}
return cancelled + this.cancelAllWaiters();
}
} }
export function classifyGenerationError(error: unknown): ErrorKind { export function classifyGenerationError(error: unknown): ErrorKind {
if (error instanceof GenerationJobCancelledError) return 'cancelled'; if (error instanceof GenerationJobCancelledError) return 'cancelled';
const errObj = error as { code?: string; message?: string } | null; const errObj = error as { code?: string; message?: string } | null;
if (errObj?.code === 'cancelled') return 'cancelled'; if (errObj?.code === 'cancelled') return 'cancelled';
// Structured CLI error short-circuits message-regex matching for the deterministic cases.
// Duck-typed to avoid circular import with ./cli.
const details = (error as { details?: { reason?: string } } | null)?.details;
if (details && typeof details.reason === 'string') {
switch (details.reason) {
case 'wall-timeout':
return 'timeout';
case 'spawn-failure':
case 'startup-error':
return 'config';
case 'streams-unavailable':
return 'unknown';
// exit-nonzero falls through: stderr might carry auth/rate-limit/schema info.
}
}
const message = String(errObj?.message || error); const message = String(errObj?.message || error);
if (/api key|unauthorized|401|403|认证|权限/i.test(message)) return 'auth'; if (/api key|unauthorized|401|403|认证|权限/i.test(message)) return 'auth';
if (/timeout|超时|timed out/i.test(message)) return 'timeout'; if (/timeout|超时|timed out/i.test(message)) return 'timeout';
if (/429|rate limit|too many requests/i.test(message)) return 'rate-limit'; if (/429|rate limit|too many requests/i.test(message)) return 'rate-limit';
if (/非 JSON|json_schema|schema|structured/i.test(message)) return 'schema'; if (
/ECONNREFUSED|ENOTFOUND|ENETUNREACH|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|Failed to fetch|NetworkError|net::ERR_|fetch failed/i.test(
message,
)
)
return 'network';
if (
/非 JSON|非预期输出|没有返回结果|non-JSON|unexpected output|no result|json_schema|schema|structured/i.test(message)
)
return 'schema';
if (/model 未设置|base url|配置|config/i.test(message)) return 'config'; if (/model 未设置|base url|配置|config/i.test(message)) return 'config';
return 'unknown'; return 'unknown';
} }

View file

@ -1,7 +1,7 @@
'use strict'; 'use strict';
import { requestUrl } from 'obsidian'; import { requestUrl } from 'obsidian';
import { findLineForAnchor } from './anchor'; import { buildLineOffsets, findLineForAnchor } from './anchor';
import { summarizeViaClaudeCode, summarizeViaCodex } from './cli'; import { summarizeViaClaudeCode, summarizeViaCodex } from './cli';
import type { GenerationJob } from './generation-job-manager'; import type { GenerationJob } from './generation-job-manager';
import { buildPrompts } from './prompt'; import { buildPrompts } from './prompt';
@ -30,19 +30,27 @@ export async function summarizeDocument(
if (useStreaming) { if (useStreaming) {
const abortController = new AbortController(); const abortController = new AbortController();
job.onCancel(() => abortController.abort()); job.onCancel(() => abortController.abort());
cards = await summarizeViaApiStreaming(system, user, settings, onStreamProgress, abortController.signal); cards = await summarizeViaApiStreaming(
requestUrl,
system,
user,
settings,
onStreamProgress,
abortController.signal,
);
} else { } else {
cards = await summarizeViaApi(requestUrl, system, user, settings); cards = await summarizeViaApi(requestUrl, system, user, settings);
} }
break; break;
} }
} }
const lineOffsets = buildLineOffsets(content);
const resolved: ResolvedCard[] = cards.map((c) => ({ const resolved: ResolvedCard[] = cards.map((c) => ({
title: c.title, title: c.title,
level: 2, level: 2,
anchor: c.anchor, anchor: c.anchor,
gist: c.gist, gist: c.gist,
startLine: findLineForAnchor(content, c.anchor), startLine: findLineForAnchor(content, c.anchor, lineOffsets),
bullets: c.bullets, bullets: c.bullets,
})); }));
resolved.sort((a, b) => { resolved.sort((a, b) => {

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,23 @@
import { STRINGS } from './i18n-strings'; import { STRINGS } from './i18n-strings';
import type { PluginSettings } from './types'; import type { PluginSettings } from './types';
export { STRINGS } from './i18n-strings'; export { LOCALE_OVERRIDES, STRINGS } from './i18n-strings';
function supportedBaseLanguage(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const base = value.trim().toLowerCase().split(/[-_]/)[0];
return base && STRINGS[base] ? base : null;
}
export function resolveUiLanguage(settings: Pick<PluginSettings, 'uiLanguage'> | null): string { export function resolveUiLanguage(settings: Pick<PluginSettings, 'uiLanguage'> | null): string {
const configured = settings?.uiLanguage; const configured = settings?.uiLanguage;
if (configured === 'zh' || configured === 'en') return configured; if (configured && configured !== 'auto') {
return supportedBaseLanguage(configured) || 'en';
}
const nav = typeof navigator !== 'undefined' ? navigator : null; const nav = typeof navigator !== 'undefined' ? navigator : null;
const language = String(nav?.language || '').toLowerCase(); return supportedBaseLanguage(nav?.language) || 'en';
return language.startsWith('zh') ? 'zh' : 'en';
} }
export function translate( export function translate(

View file

@ -4,7 +4,13 @@ import { type App, Modal } from 'obsidian';
import type { CardPatch, PluginHost, ResolvedCard } from './types'; import type { CardPatch, PluginHost, ResolvedCard } from './types';
import { addTextButton } from './ui-helpers'; import { addTextButton } from './ui-helpers';
export function confirmRegenerateEditedCards(app: App, title: string, message: string): Promise<boolean> { function confirmAction(
app: App,
title: string,
message: string,
cancelText: string,
confirmText: string,
): Promise<boolean> {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
let settled = false; let settled = false;
const settle = (value: boolean) => { const settle = (value: boolean) => {
@ -16,11 +22,11 @@ export function confirmRegenerateEditedCards(app: App, title: string, message: s
modal.titleEl.setText(title); modal.titleEl.setText(title);
modal.contentEl.createEl('p', { text: message }); modal.contentEl.createEl('p', { text: message });
const btnRow = modal.contentEl.createDiv({ cls: 'modal-button-container' }); const btnRow = modal.contentEl.createDiv({ cls: 'modal-button-container' });
btnRow.createEl('button', { text: 'Cancel' }).addEventListener('click', () => { btnRow.createEl('button', { text: cancelText }).addEventListener('click', () => {
modal.close(); modal.close();
settle(false); settle(false);
}); });
btnRow.createEl('button', { text: 'OK', cls: 'mod-cta' }).addEventListener('click', () => { btnRow.createEl('button', { text: confirmText, cls: 'mod-cta' }).addEventListener('click', () => {
modal.close(); modal.close();
settle(true); settle(true);
}); });
@ -29,6 +35,26 @@ export function confirmRegenerateEditedCards(app: App, title: string, message: s
}); });
} }
export function confirmRegenerateEditedCards(
app: App,
title: string,
message: string,
cancelText: string,
confirmText: string,
): Promise<boolean> {
return confirmAction(app, title, message, cancelText, confirmText);
}
export function confirmExportOverwrite(
app: App,
title: string,
message: string,
cancelText: string,
confirmText: string,
): Promise<boolean> {
return confirmAction(app, title, message, cancelText, confirmText);
}
export class CardEditModal extends Modal { export class CardEditModal extends Modal {
plugin: PluginHost; plugin: PluginHost;
card: ResolvedCard; card: ResolvedCard;

View file

@ -1,23 +1,53 @@
'use strict'; 'use strict';
import { DEFAULT_SETTINGS, MAX_DOC_CHARS, PROMPT_LANGUAGES } from './settings'; import { DEFAULT_SETTINGS, MAX_DOC_CHARS, normalizeCardCount, PROMPT_LANGUAGES } from './settings';
import type { PluginSettings, PromptPair } from './types'; import type { PluginSettings, PromptPair } from './types';
const PROMPT_LANGUAGE_INSTRUCTIONS: Record<string, string> = {
auto: 'Write title, gist, and bullets in the main language of the source document.',
zh: '用中文输出 title、gist 和 bullets。',
en: 'Write title, gist, and bullets in English.',
ja: 'Write title, gist, and bullets in Japanese.',
ko: 'Write title, gist, and bullets in Korean.',
fr: 'Write title, gist, and bullets in French.',
de: 'Write title, gist, and bullets in German.',
es: 'Write title, gist, and bullets in Spanish.',
};
const PROMPT_SCHEMA_EXAMPLES: Record<string, string> = {
zh: `{"cards":[
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/742% 报告收益模糊"]}
]}`,
en: `{"cards":[
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
]}`,
ja: `{"cards":[
{"title":"U字型の利益","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AIによる生産性向上はU字型になり、両端の層がもっとも大きな恩恵を受ける","bullets":["高所得のソフトウェア管理職は既存業務を加速できるため大きく恩恵を受ける","低所得の労働者はAIを使って新しい副収入を作り出せる","中間層の専門職は、プロンプト精度への信頼が難しいため利益が小さい","平均利益は5.1/7で、42%が効果は不明確だと報告している"]}
]}`,
ko: `{"cards":[
{"title":"U자형 이득","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI 생산성 향상은 U자형을 보이며 양끝 집단이 가장 큰 혜택을 얻는다","bullets":["고소득 소프트웨어 관리자는 기존 업무를 더 빠르게 처리해 큰 이득을 얻는다","저소득 노동자는 AI로 새로운 부수입 기회를 만들 수 있다","중간층 전문가는 프롬프트 정확도를 신뢰하기 어려워 상대적으로 이득이 작다","평균 체감 이득은 5.1/7이며 42%는 효과가 불명확하다고 답했다"]}
]}`,
fr: `{"cards":[
{"title":"Gains en U","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"Les gains de productivité liés à l'AI forment une courbe en U, les deux extrémités en profitant le plus","bullets":["Les managers logiciels très rémunérés gagnent surtout en accélérant leur travail existant","Les travailleurs peu rémunérés utilisent l'AI pour créer de nouveaux revenus complémentaires","Les spécialistes intermédiaires gagnent moins, car la précision des prompts reste difficile à fiabiliser","Le bénéfice moyen déclaré est de 5,1/7, avec 42% de gains jugés peu clairs"]}
]}`,
de: `{"cards":[
{"title":"U-förmige Gewinne","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI-Produktivitätsgewinne bilden eine U-Form, bei der beide Enden am stärksten profitieren","bullets":["Hochbezahlte Softwaremanager profitieren, weil sie bestehende Arbeit beschleunigen","Geringverdienende nutzen AI, um neue Nebeneinnahmen zu schaffen","Spezialisten in der Mitte gewinnen weniger, weil präzise Prompts schwer zu vertrauen sind","Der gemeldete Durchschnittsnutzen liegt bei 5,1/7; 42% beschreiben die Gewinne als unklar"]}
]}`,
es: `{"cards":[
{"title":"Ganancias en forma de U","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"Las mejoras de productividad con AI forman una U: los extremos son quienes más se benefician","bullets":["Los gerentes de software mejor pagados se benefician al acelerar su trabajo existente","Los trabajadores con menores ingresos usan AI para crear nuevas fuentes de ingreso","Los especialistas intermedios ganan menos porque es difícil confiar en la precisión del prompt","El beneficio medio reportado es 5,1/7, con un 42% que describe las ganancias como poco claras"]}
]}`,
};
function usesEnglishPromptShell(language: string): boolean {
return language !== 'zh' && language !== 'auto';
}
export function promptLanguageInstruction(language: string): string { export function promptLanguageInstruction(language: string): string {
if (language === 'en') return 'Write title, gist, and bullets in English.'; return PROMPT_LANGUAGE_INSTRUCTIONS[language] || PROMPT_LANGUAGE_INSTRUCTIONS.zh;
if (language === 'auto') return 'Write title, gist, and bullets in the main language of the source document.';
return '用中文输出 title、gist 和 bullets。';
} }
export function promptSchemaExample(language: string): string { export function promptSchemaExample(language: string): string {
if (language === 'en') { return PROMPT_SCHEMA_EXAMPLES[language] || PROMPT_SCHEMA_EXAMPLES.zh;
return `{"cards":[
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
]}`;
}
return `{"cards":[
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/742% 报告收益模糊"]}
]}`;
} }
export function renderPromptTemplate(template: string, vars: Record<string, string | number>): string { export function renderPromptTemplate(template: string, vars: Record<string, string | number>): string {
@ -34,7 +64,7 @@ function defaultSystemPrompt(
schema: string, schema: string,
example: string, example: string,
): string { ): string {
if (language === 'en') { if (usesEnglishPromptShell(language)) {
return `You are a long-form reading summary assistant. After reading the full document, split it into ${minCards}-${maxCards} natural topic units. They do not need to match markdown headings; use a complete argument or topic as the unit, merging short sections and splitting long ones when needed. return `You are a long-form reading summary assistant. After reading the full document, split it into ${minCards}-${maxCards} natural topic units. They do not need to match markdown headings; use a complete argument or topic as the unit, merging short sections and splitting long ones when needed.
Each card has one guiding sentence plus several bullets. Bullets carry details; gist is the lead-in. Each card has one guiding sentence plus several bullets. Bullets carry details; gist is the lead-in.
@ -98,7 +128,7 @@ function systemPromptContract(
languageInstruction: string, languageInstruction: string,
schema: string, schema: string,
): string { ): string {
if (language === 'en') { if (usesEnglishPromptShell(language)) {
return `Non-overridable output contract: return `Non-overridable output contract:
- Must output ${minCards}-${maxCards} cards. - Must output ${minCards}-${maxCards} cards.
- ${languageInstruction} - ${languageInstruction}
@ -119,13 +149,13 @@ export function buildPrompts(content: string, settings: PluginSettings): PromptP
const promptLanguage = (PROMPT_LANGUAGES as Record<string, string>)[settings.promptLanguage] const promptLanguage = (PROMPT_LANGUAGES as Record<string, string>)[settings.promptLanguage]
? settings.promptLanguage ? settings.promptLanguage
: DEFAULT_SETTINGS.promptLanguage; : DEFAULT_SETTINGS.promptLanguage;
const minCards = Math.max(1, Number(settings.minCards) || DEFAULT_SETTINGS.minCards); const minCards = normalizeCardCount(settings.minCards, DEFAULT_SETTINGS.minCards);
const maxCards = Math.max(minCards, Number(settings.maxCards) || DEFAULT_SETTINGS.maxCards); const maxCards = Math.max(minCards, normalizeCardCount(settings.maxCards, DEFAULT_SETTINGS.maxCards));
const languageInstruction = promptLanguageInstruction(promptLanguage); const languageInstruction = promptLanguageInstruction(promptLanguage);
const doc = const doc =
content.length > maxDocChars content.length > maxDocChars
? content.slice(0, maxDocChars) + ? content.slice(0, maxDocChars) +
(promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]') (usesEnglishPromptShell(promptLanguage) ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
: content; : content;
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}'; const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
@ -141,6 +171,8 @@ export function buildPrompts(content: string, settings: PluginSettings): PromptP
${contract}` ${contract}`
: defaultSystem; : defaultSystem;
const user = promptLanguage === 'en' ? `Source document:\n\n${doc}` : `以下是需要处理的文档全文:\n\n${doc}`; const user = usesEnglishPromptShell(promptLanguage)
? `Source document:\n\n${doc}`
: `以下是需要处理的文档全文:\n\n${doc}`;
return { system, user }; return { system, user };
} }

View file

@ -12,7 +12,7 @@ import type { PluginSettings } from './types';
/* ---------- Body type interfaces ---------- */ /* ---------- Body type interfaces ---------- */
export interface AnthropicMessagesBody { interface AnthropicMessagesBody {
model: string; model: string;
max_tokens: number; max_tokens: number;
system: string; system: string;
@ -22,7 +22,7 @@ export interface AnthropicMessagesBody {
stream?: boolean; stream?: boolean;
} }
export interface OpenAiChatBody { interface OpenAiChatBody {
model: string; model: string;
messages: Array<{ role: string; content: string }>; messages: Array<{ role: string; content: string }>;
response_format?: unknown; response_format?: unknown;
@ -30,7 +30,7 @@ export interface OpenAiChatBody {
[tokenField: string]: unknown; [tokenField: string]: unknown;
} }
export interface OpenAiResponsesBody { interface OpenAiResponsesBody {
model: string; model: string;
instructions: string; instructions: string;
input: string; input: string;
@ -46,7 +46,7 @@ interface GeminiGenerationConfig {
responseJsonSchema?: unknown; responseJsonSchema?: unknown;
} }
export interface GeminiBody { interface GeminiBody {
systemInstruction: { parts: Array<{ text: string }> }; systemInstruction: { parts: Array<{ text: string }> };
contents: Array<{ role: string; parts: Array<{ text: string }> }>; contents: Array<{ role: string; parts: Array<{ text: string }> }>;
generationConfig: GeminiGenerationConfig; generationConfig: GeminiGenerationConfig;

View file

@ -55,7 +55,7 @@ export function cardsFromAnthropicToolUse(
const block = content.find((c) => c && c.type === 'tool_use' && c.name === ANTHROPIC_CARD_TOOL_NAME); const block = content.find((c) => c && c.type === 'tool_use' && c.name === ANTHROPIC_CARD_TOOL_NAME);
if (!block) return null; if (!block) return null;
if (typeof block.input === 'string') return parseCardsJson(block.input, settings); if (typeof block.input === 'string') return parseCardsJson(block.input, settings);
if (block.input && typeof block.input === 'object') return normalizeCardsPayload(block.input); if (block.input && typeof block.input === 'object') return normalizeCardsPayload(block.input, settings);
return []; return [];
} }

View file

@ -11,6 +11,22 @@ export type RequestUrlFunction = (params: {
throw?: boolean; throw?: boolean;
}) => Promise<{ status: number; json: unknown; text: string }>; }) => Promise<{ status: number; json: unknown; text: string }>;
/**
* Thrown when a provider responds with HTTP >= 400. Carries the raw status code
* and response body so callers can make locale-independent decisions (e.g. the
* structured-output fallback) instead of pattern-matching a translated message.
*/
export class ProviderApiError extends Error {
readonly status: number;
readonly body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = 'ProviderApiError';
this.status = status;
this.body = body;
}
}
export function endpointUrl(baseUrl: string, suffixes: string[]) { export function endpointUrl(baseUrl: string, suffixes: string[]) {
const base = baseUrl.replace(/\/+$/, ''); const base = baseUrl.replace(/\/+$/, '');
for (const suffix of suffixes) { for (const suffix of suffixes) {
@ -65,24 +81,43 @@ export async function requestJsonBody(
} }
if (resp.status >= 400) { if (resp.status >= 400) {
throw new Error( throw new ProviderApiError(
translate(settings || null, 'errorProviderApiStatus', { translate(settings || null, 'errorProviderApiStatus', {
label, label,
status: resp.status, status: resp.status,
excerpt: (resp.text || '').slice(0, 500), excerpt: (resp.text || '').slice(0, 500),
}), }),
resp.status,
resp.text || '',
); );
} }
return responseJson(resp, label, settings); return responseJson(resp, label, settings);
} }
// Bare `unknown`/`unrecognized` were intentionally dropped: they false-positive on
// model-name errors ("unknown model", "unrecognized model ID") and trigger a wasted
// fallback retry. The specific feature tokens + bare `schema` cover real structured-
// output rejections (e.g. "Unknown field: responseSchema" still matches `schema`).
const STRUCTURED_OUTPUT_REJECTION_KEYWORDS =
/response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|schema/i;
const STRUCTURED_OUTPUT_FALLBACK_STATUSES = new Set([400, 404, 422]);
export function shouldRetryWithoutStructuredOutput(error: unknown): boolean { export function shouldRetryWithoutStructuredOutput(error: unknown): boolean {
// Preferred path: decide on the locale-independent HTTP status + raw provider body.
// The error message is i18n-translated, so matching it would only work for the two
// languages whose templates happen to contain the English/Chinese status phrasing.
if (error instanceof ProviderApiError) {
if (!STRUCTURED_OUTPUT_FALLBACK_STATUSES.has(error.status)) return false;
return (
STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.body) || STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.message)
);
}
// Fallback for errors without a status (e.g. wrapped transport failures): keep the
// legacy English/Chinese template match so existing behavior is preserved.
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message)) if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message))
return false; return false;
return /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test( return STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(message);
message,
);
} }
export async function requestJsonBodyWithStructuredFallback( export async function requestJsonBodyWithStructuredFallback(

View file

@ -25,7 +25,7 @@ import {
getApiPreset, getApiPreset,
modelForApi, modelForApi,
} from './settings'; } from './settings';
import { deltaExtractorForFormat, type StreamProgress, streamingFetch } from './streaming'; import { deltaExtractorForFormat, type StreamProgress, streamingRequestUrl } from './streaming';
import type { PluginSettings, RawCard } from './types'; import type { PluginSettings, RawCard } from './types';
export { export {
@ -193,7 +193,7 @@ async function summarizeViaGoogleGenerativeAi(
return parseCardsJson(textFromGoogleGenerativeAiResponse(json), settings); return parseCardsJson(textFromGoogleGenerativeAiResponse(json), settings);
} }
// Obsidian's requestUrl is not directly compatible with fetch — we accept it as a typed callback // Accept Obsidian's requestUrl as a typed callback so tests can inject the transport.
export async function summarizeViaApi( export async function summarizeViaApi(
requestUrlImpl: RequestUrlFunction, requestUrlImpl: RequestUrlFunction,
system: string, system: string,
@ -220,6 +220,7 @@ export function supportsStreaming(settings: PluginSettings): boolean {
} }
async function streamSummarizeViaOpenAiChat( async function streamSummarizeViaOpenAiChat(
requestUrlImpl: RequestUrlFunction,
system: string, system: string,
user: string, user: string,
settings: PluginSettings, settings: PluginSettings,
@ -231,11 +232,12 @@ async function streamSummarizeViaOpenAiChat(
const body = buildOpenAiChatBody(system, user, settings, { structured: false }); const body = buildOpenAiChatBody(system, user, settings, { structured: false });
body.stream = true; body.stream = true;
const extractor = requiredDeltaExtractor('openai-chat'); const extractor = requiredDeltaExtractor('openai-chat');
const text = await streamingFetch(url, headers, body, extractor, onProgress, signal, settings); const text = await streamingRequestUrl(requestUrlImpl, url, headers, body, extractor, onProgress, signal, settings);
return parseCardsJson(text.trim(), settings); return parseCardsJson(text.trim(), settings);
} }
async function streamSummarizeViaAnthropicMessages( async function streamSummarizeViaAnthropicMessages(
requestUrlImpl: RequestUrlFunction,
system: string, system: string,
user: string, user: string,
settings: PluginSettings, settings: PluginSettings,
@ -247,11 +249,12 @@ async function streamSummarizeViaAnthropicMessages(
const body = buildAnthropicMessagesBody(system, user, settings, { structured: false }); const body = buildAnthropicMessagesBody(system, user, settings, { structured: false });
body.stream = true; body.stream = true;
const extractor = requiredDeltaExtractor('anthropic-messages'); const extractor = requiredDeltaExtractor('anthropic-messages');
const text = await streamingFetch(url, headers, body, extractor, onProgress, signal, settings); const text = await streamingRequestUrl(requestUrlImpl, url, headers, body, extractor, onProgress, signal, settings);
return parseCardsJson(text.trim(), settings); return parseCardsJson(text.trim(), settings);
} }
export async function summarizeViaApiStreaming( export async function summarizeViaApiStreaming(
requestUrlImpl: RequestUrlFunction,
system: string, system: string,
user: string, user: string,
settings: PluginSettings, settings: PluginSettings,
@ -261,15 +264,15 @@ export async function summarizeViaApiStreaming(
const format = getApiFormat(settings); const format = getApiFormat(settings);
switch (format) { switch (format) {
case 'openai-chat': case 'openai-chat':
return streamSummarizeViaOpenAiChat(system, user, settings, onProgress, signal); return streamSummarizeViaOpenAiChat(requestUrlImpl, system, user, settings, onProgress, signal);
case 'anthropic-messages': case 'anthropic-messages':
return streamSummarizeViaAnthropicMessages(system, user, settings, onProgress, signal); return streamSummarizeViaAnthropicMessages(requestUrlImpl, system, user, settings, onProgress, signal);
default: default:
throw new Error(`Streaming not supported for format: ${format}`); throw new Error(`Streaming not supported for format: ${format}`);
} }
} }
// Obsidian's requestUrl is not directly compatible with fetch — we accept it as a typed callback // Accept Obsidian's requestUrl as a typed callback so tests can inject the transport.
export async function testApiBackend(requestUrlImpl: RequestUrlFunction, settings: PluginSettings): Promise<string> { export async function testApiBackend(requestUrlImpl: RequestUrlFunction, settings: PluginSettings): Promise<string> {
await summarizeViaApi(requestUrlImpl, '只输出 JSON{"cards":[]}', '连通性测试:请原样输出 {"cards":[]}', settings); await summarizeViaApi(requestUrlImpl, '只输出 JSON{"cards":[]}', '连通性测试:请原样输出 {"cards":[]}', settings);
const format = getApiFormat(settings); const format = getApiFormat(settings);

View file

@ -48,7 +48,7 @@ export function extractJson(text: string): string {
try { try {
JSON.parse(raw); JSON.parse(raw);
return raw; return raw;
} catch (_) { } catch {
/* continue */ /* continue */
} }
@ -58,7 +58,7 @@ export function extractJson(text: string): string {
try { try {
JSON.parse(fenced); JSON.parse(fenced);
return fenced; return fenced;
} catch (_) { } catch {
/* continue */ /* continue */
} }
} }
@ -69,7 +69,7 @@ export function extractJson(text: string): string {
try { try {
JSON.parse(c); JSON.parse(c);
return c; return c;
} catch (_) { } catch {
/* skip */ /* skip */
} }
} }
@ -94,7 +94,7 @@ export function repairTruncatedCardsJson(text: string): string | null {
try { try {
JSON.parse(c); JSON.parse(c);
validCards.push(c); validCards.push(c);
} catch (_) { } catch {
/* skip malformed card */ /* skip malformed card */
} }
} }
@ -107,7 +107,7 @@ export function parseCardsJson(text: string, settings?: PluginSettings | null):
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(jsonText); parsed = JSON.parse(jsonText);
} catch (_e) { } catch {
// Attempt to salvage complete cards from truncated output // Attempt to salvage complete cards from truncated output
const repaired = repairTruncatedCardsJson(text); const repaired = repairTruncatedCardsJson(text);
if (repaired) { if (repaired) {
@ -118,28 +118,34 @@ export function parseCardsJson(text: string, settings?: PluginSettings | null):
(parsed as { cards?: unknown[] }).cards?.length ?? 0, (parsed as { cards?: unknown[] }).cards?.length ?? 0,
'complete cards. Consider increasing max tokens.', 'complete cards. Consider increasing max tokens.',
); );
return normalizeCardsPayload(parsed); return normalizeCardsPayload(parsed, settings);
} catch (_) { } catch {
/* repair failed, fall through to error */ /* repair failed, fall through to error */
} }
} }
console.warn('[parallel-reader] LLM returned non-JSON. Raw response:', text); console.warn(
'[parallel-reader] LLM returned non-JSON. length=',
(text || '').length,
'head=',
(text || '').slice(0, 80),
);
throw new Error( throw new Error(
translate(settings || null, 'errorLlmNonJson', { translate(settings || null, 'errorLlmNonJson', {
excerpt: (text || '').slice(0, 500), length: String((text || '').length),
}), }),
); );
} }
return normalizeCardsPayload(parsed); return normalizeCardsPayload(parsed, settings);
} }
export function normalizeCardsPayload(parsed: unknown): RawCard[] { export function normalizeCardsPayload(parsed: unknown, settings?: PluginSettings | null): RawCard[] {
const obj = parsed as { cards?: unknown[] } | null | undefined; const obj = parsed as { cards?: unknown[] } | null | undefined;
const raw = obj && Array.isArray(obj.cards) ? obj.cards : []; const raw = obj && Array.isArray(obj.cards) ? obj.cards : [];
const fallbackTitle = translate(settings ?? null, 'cardUntitled');
return raw return raw
.filter((c): c is Record<string, unknown> => !!c && typeof c === 'object') .filter((c): c is Record<string, unknown> => !!c && typeof c === 'object')
.map((c) => ({ .map((c) => ({
title: typeof c.title === 'string' ? c.title : '(无标题)', title: typeof c.title === 'string' ? c.title : fallbackTitle,
anchor: typeof c.anchor === 'string' ? c.anchor : '', anchor: typeof c.anchor === 'string' ? c.anchor : '',
gist: typeof c.gist === 'string' ? c.gist : '', gist: typeof c.gist === 'string' ? c.gist : '',
bullets: Array.isArray(c.bullets) bullets: Array.isArray(c.bullets)

View file

@ -29,12 +29,12 @@ export function visibleTopProbeY(
type ScheduleId = { readonly __brand: 'ScheduleId'; readonly raw: number | ReturnType<typeof setTimeout> }; type ScheduleId = { readonly __brand: 'ScheduleId'; readonly raw: number | ReturnType<typeof setTimeout> };
function wrapId(raw: number | ReturnType<typeof setTimeout>): ScheduleId { function wrapId(raw: number | ReturnType<typeof setTimeout>): ScheduleId {
return { __brand: 'ScheduleId', raw } as ScheduleId; return { __brand: 'ScheduleId', raw };
} }
function defaultSchedule(callback: FrameRequestCallback): ScheduleId { function defaultSchedule(callback: FrameRequestCallback): ScheduleId {
if (typeof requestAnimationFrame === 'function') return wrapId(requestAnimationFrame(callback)); if (typeof requestAnimationFrame === 'function') return wrapId(requestAnimationFrame(callback));
return wrapId(setTimeout(() => callback(Date.now()), FALLBACK_FRAME_MS)); return wrapId(activeWindow.setTimeout(() => callback(Date.now()), FALLBACK_FRAME_MS));
} }
function defaultCancel(id: ScheduleId) { function defaultCancel(id: ScheduleId) {
@ -42,7 +42,7 @@ function defaultCancel(id: ScheduleId) {
cancelAnimationFrame(id.raw as number); cancelAnimationFrame(id.raw as number);
return; return;
} }
clearTimeout(id.raw as ReturnType<typeof setTimeout>); activeWindow.clearTimeout(id.raw as number);
} }
export function createRafThrottledHandler( export function createRafThrottledHandler(

View file

@ -1,8 +1,7 @@
'use strict'; 'use strict';
import { type App, Notice, type Plugin, PluginSettingTab, requestUrl, Setting } from 'obsidian'; import { type App, Notice, type Plugin, PluginSettingTab, Setting } from 'obsidian';
import { resolveCliPath, runCli } from './cli'; import { testBackend } from './backend-test';
import { testApiBackend } from './providers';
import { import {
API_AUTH_TYPES, API_AUTH_TYPES,
API_FORMATS, API_FORMATS,
@ -12,24 +11,27 @@ import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
getApiFormat, getApiFormat,
getApiPreset, getApiPreset,
normalizeCardCount,
normalizeCliTimeoutMs,
normalizeStreamingTimeoutMs, normalizeStreamingTimeoutMs,
PROMPT_LANGUAGES, PROMPT_LANGUAGES,
UI_LANGUAGES, UI_LANGUAGES,
} from './settings'; } from './settings';
import type { PluginHost, PluginSettings } from './types'; import type { PluginHost, PluginSettings } from './types';
async function testBackend(settings: PluginSettings) { /** Detect whether the user has departed from preset defaults. If so we keep the
if (settings.backend === 'claude-code') { * Advanced connection section open so they can find what they configured. */
const cmd = resolveCliPath('claude', settings.cliPath); function shouldOpenAdvancedConnection(settings: PluginSettings): boolean {
const { stdout } = await runCli(cmd, ['--version'], '', 10000); if ((settings.apiProvider || '').startsWith('custom-')) return true;
return `claude @ ${cmd}\n${stdout.trim()}`; if ((settings.apiHeaders || '').trim()) return true;
} const preset = getApiPreset(settings);
if (settings.backend === 'codex') { const baseUrl = (settings.apiBaseUrl || '').trim();
const cmd = resolveCliPath('codex', settings.cliPath); if (baseUrl && preset.baseUrl && baseUrl.replace(/\/+$/, '') !== preset.baseUrl.replace(/\/+$/, '')) return true;
const { stdout } = await runCli(cmd, ['--version'], '', 10000); if (settings.apiAuthType && settings.apiAuthType !== 'auto') return true;
return `codex @ ${cmd}\n${stdout.trim()}`; if (settings.streaming === false) return true;
} if (settings.apiMaxTokens && settings.apiMaxTokens !== DEFAULT_SETTINGS.apiMaxTokens) return true;
return testApiBackend(requestUrl, settings); if (settings.apiFormat && preset.format && settings.apiFormat !== preset.format) return true;
return false;
} }
export class ParallelReaderSettingTab extends PluginSettingTab { export class ParallelReaderSettingTab extends PluginSettingTab {
@ -47,31 +49,27 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
display() { display() {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
new Setting(containerEl).setName(this.tr('settingsTitle')).setHeading(); containerEl.addClass('parallel-reader-settings');
this.renderGeneralSection(containerEl);
const isCliBacked = this.plugin.settings.backend === 'claude-code' || this.plugin.settings.backend === 'codex'; const isCliBacked = this.plugin.settings.backend === 'claude-code' || this.plugin.settings.backend === 'codex';
this.renderBackendSection(containerEl, isCliBacked);
this.renderPromptSection(containerEl, isCliBacked); this.renderQuickSetup(containerEl, isCliBacked);
this.renderActionsSection(containerEl, isCliBacked); this.renderReadingOutput(containerEl);
this.renderCacheSection(containerEl);
if (!isCliBacked) {
this.renderAdvancedConnection(containerEl);
} else {
this.renderAdvancedConnectionCli(containerEl);
}
this.renderAdvancedPrompt(containerEl);
this.renderCacheMaintenance(containerEl);
} }
private renderGeneralSection(containerEl: HTMLElement) { /* ---------- 1. Quick setup (always expanded) ---------- */
new Setting(containerEl)
.setName(this.tr('settingUiLanguageName')) private renderQuickSetup(containerEl: HTMLElement, isCliBacked: boolean) {
.setDesc(this.tr('settingUiLanguageDesc')) new Setting(containerEl).setName(this.tr('sectionQuickSetup')).setHeading();
.addDropdown((d) => {
for (const [id, label] of Object.entries(UI_LANGUAGES)) {
d.addOption(id, label);
}
return d.setValue(this.plugin.settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage).onChange(async (v) => {
this.plugin.settings.uiLanguage = v;
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl) new Setting(containerEl)
.setName(this.tr('settingBackendName')) .setName(this.tr('settingBackendName'))
@ -94,162 +92,103 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
this.display(); this.display();
}), }),
); );
}
private renderBackendSection(containerEl: HTMLElement, isCliBacked: boolean) {
if (isCliBacked) { if (isCliBacked) {
this.renderCliBackendSettings(containerEl); new Setting(containerEl)
.setName(this.tr('settingCliPathName'))
.setDesc(this.tr('settingCliPathDesc'))
.addText((t) =>
t
.setPlaceholder(this.tr('settingCliPathPlaceholder'))
.setValue(this.plugin.settings.cliPath)
.onChange((v) => {
this.plugin.settings.cliPath = v.trim();
this.plugin.saveSettingsDebounced();
}),
);
} else { } else {
this.renderApiBackendSettings(containerEl); this.renderProviderPresetWithSummary(containerEl);
this.renderCredentialRow(containerEl);
} }
this.renderModelRow(containerEl, isCliBacked);
this.renderTestButton(containerEl, isCliBacked);
} }
private renderCliBackendSettings(containerEl: HTMLElement) { private renderProviderPresetWithSummary(containerEl: HTMLElement) {
new Setting(containerEl) const settings = this.plugin.settings;
.setName(this.tr('settingCliPathName')) const preset = getApiPreset(settings);
.setDesc(this.tr('settingCliPathDesc')) const format = getApiFormat(settings);
.addText((t) => const baseUrl = (settings.apiBaseUrl || preset.baseUrl || API_FORMATS[format]?.defaultBaseUrl || '').replace(
t /\/+$/,
.setPlaceholder(this.tr('settingCliPathPlaceholder')) '',
.setValue(this.plugin.settings.cliPath) );
.onChange((v) => {
this.plugin.settings.cliPath = v.trim();
this.plugin.saveSettingsDebounced();
}),
);
}
private renderApiBackendSettings(containerEl: HTMLElement) { const setting = new Setting(containerEl)
const preset = getApiPreset(this.plugin.settings);
new Setting(containerEl).setName(this.tr('apiProviderHeader')).setHeading();
new Setting(containerEl)
.setName(this.tr('settingProviderPresetName')) .setName(this.tr('settingProviderPresetName'))
.setDesc(this.tr('settingProviderPresetDesc')) .setDesc(this.tr('settingProviderPresetDesc'))
.addDropdown((d) => { .addDropdown((d) => {
for (const [id, entry] of Object.entries(API_PROVIDER_PRESETS)) { for (const [id, entry] of Object.entries(API_PROVIDER_PRESETS)) {
d.addOption(id, entry.label); d.addOption(id, entry.label);
} }
return d.setValue(this.plugin.settings.apiProvider).onChange(async (v) => { return d.setValue(settings.apiProvider).onChange(async (v) => {
this.plugin.settings = applyApiProviderPreset(this.plugin.settings, v); this.plugin.settings = applyApiProviderPreset(this.plugin.settings, v);
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.display(); this.display();
}); });
}); });
new Setting(containerEl) // Read-only summary of derived format + baseUrl, replacing the standalone rows.
.setName(this.tr('settingApiFormatName')) const summary = setting.descEl.createDiv({ cls: 'parallel-reader-preset-summary' });
.setDesc(this.tr('settingApiFormatDesc')) summary.createEl('code', {
.addDropdown((d) => { text: `${API_FORMATS[format]?.label || format} · ${baseUrl || this.tr('settingProviderPresetSummaryEmpty')}`,
for (const [id, entry] of Object.entries(API_FORMATS)) { });
d.addOption(id, entry.label);
}
return d.setValue(getApiFormat(this.plugin.settings)).onChange(async (v) => {
this.plugin.settings.apiFormat = v;
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName(this.tr('settingBaseUrlName'))
.setDesc(this.tr('settingBaseUrlDesc'))
.addText((t) =>
t
.setPlaceholder(
(this.plugin.settings.apiProvider || '').startsWith('custom-')
? 'https://your-provider.example/v1'
: preset.baseUrl || API_FORMATS[getApiFormat(this.plugin.settings)].defaultBaseUrl,
)
.setValue(this.plugin.settings.apiBaseUrl)
.onChange((v) => {
this.plugin.settings.apiBaseUrl = v.trim();
this.plugin.saveSettingsDebounced();
}),
);
new Setting(containerEl)
.setName(this.tr('settingApiKeyName'))
.setDesc(this.tr('settingApiKeyDesc'))
.addText((t) => {
t.inputEl.type = 'password';
return t.setValue(this.plugin.settings.apiKey).onChange((v) => {
this.plugin.settings.apiKey = v.trim();
this.plugin.saveSettingsDebounced();
});
});
new Setting(containerEl)
.setName(this.tr('settingApiKeyEnvName'))
.setDesc(this.tr('settingApiKeyEnvDesc'))
.addText((t) =>
t
.setPlaceholder(preset.envVar || 'OPENAI_API_KEY')
.setValue(this.plugin.settings.apiKeyEnvVar)
.onChange((v) => {
this.plugin.settings.apiKeyEnvVar = v.trim();
this.plugin.saveSettingsDebounced();
}),
);
new Setting(containerEl)
.setName(this.tr('settingAuthTypeName'))
.setDesc(this.tr('settingAuthTypeDesc'))
.addDropdown((d) => {
for (const [id, label] of Object.entries(API_AUTH_TYPES)) {
d.addOption(id, label);
}
return d.setValue(this.plugin.settings.apiAuthType || 'auto').onChange(async (v) => {
this.plugin.settings.apiAuthType = v;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(this.tr('settingHeadersName'))
.setDesc(this.tr('settingHeadersDesc'))
.addTextArea((t) =>
t.setValue(this.plugin.settings.apiHeaders).onChange((v) => {
this.plugin.settings.apiHeaders = v;
this.plugin.saveSettingsDebounced();
}),
);
new Setting(containerEl).setName(this.tr('settingMaxTokensName')).addText((t) =>
t.setValue(String(this.plugin.settings.apiMaxTokens)).onChange((v) => {
const n = parseInt(v, 10);
if (!Number.isNaN(n) && n > 0) {
this.plugin.settings.apiMaxTokens = n;
this.plugin.saveSettingsDebounced();
}
}),
);
new Setting(containerEl)
.setName(this.tr('settingStreamingName'))
.setDesc(this.tr('settingStreamingDesc'))
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.streaming ?? true).onChange(async (v) => {
this.plugin.settings.streaming = v;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(this.tr('settingStreamingTimeoutName'))
.setDesc(this.tr('settingStreamingTimeoutDesc'))
.addText((t) =>
t
.setPlaceholder(String(DEFAULT_SETTINGS.streamingTimeoutMs))
.setValue(String(this.plugin.settings.streamingTimeoutMs || DEFAULT_SETTINGS.streamingTimeoutMs))
.onChange((v) => {
this.plugin.settings.streamingTimeoutMs = normalizeStreamingTimeoutMs(v);
this.plugin.saveSettingsDebounced();
}),
);
} }
private renderPromptSection(containerEl: HTMLElement, isCliBacked: boolean) { private renderCredentialRow(containerEl: HTMLElement) {
const settings = this.plugin.settings;
const preset = getApiPreset(settings);
const setting = new Setting(containerEl)
.setName(this.tr('settingApiKeyName'))
.setDesc(this.tr('settingApiKeyDesc'));
setting.addText((t) => {
t.inputEl.type = 'password';
t.inputEl.autocomplete = 'off';
return t.setValue(settings.apiKey).onChange((v) => {
this.plugin.settings.apiKey = v.trim();
this.plugin.saveSettingsDebounced();
});
});
// Env-var fallback: render manually (without `new Setting`) to avoid
// nesting Obsidian .setting-item inside another controlEl, which causes
// theme CSS conflicts (extra borders/padding from `.setting-item:first-child`).
const envWrap = setting.controlEl.createDiv({ cls: 'parallel-reader-env-wrap' });
const envDetails = envWrap.createEl('details');
envDetails.createEl('summary', { text: this.tr('settingApiKeyEnvSummary') });
const row = envDetails.createDiv({ cls: 'parallel-reader-env-row' });
row.createEl('label', {
text: this.tr('settingApiKeyEnvName'),
cls: 'parallel-reader-env-label',
});
const envInput = row.createEl('input', {
type: 'text',
cls: 'parallel-reader-env-input',
});
envInput.placeholder = preset.envVar || 'OPENAI_API_KEY';
envInput.value = settings.apiKeyEnvVar || '';
envInput.addEventListener('input', () => {
this.plugin.settings.apiKeyEnvVar = envInput.value.trim();
this.plugin.saveSettingsDebounced();
});
envDetails.createDiv({
text: this.tr('settingApiKeyEnvDesc'),
cls: 'parallel-reader-env-desc',
});
}
private renderModelRow(containerEl: HTMLElement, isCliBacked: boolean) {
new Setting(containerEl) new Setting(containerEl)
.setName(this.tr('settingModelName')) .setName(this.tr('settingModelName'))
.setDesc(isCliBacked ? this.tr('settingModelDescCli') : this.tr('settingModelDescApi')) .setDesc(isCliBacked ? this.tr('settingModelDescCli') : this.tr('settingModelDescApi'))
@ -262,21 +201,33 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
this.plugin.saveSettingsDebounced(); this.plugin.saveSettingsDebounced();
}), }),
); );
}
private renderTestButton(containerEl: HTMLElement, isCliBacked: boolean) {
new Setting(containerEl) new Setting(containerEl)
.setName(this.tr('settingMaxInputName')) .setName(this.tr('settingTestBackendName'))
.setDesc(this.tr('settingMaxInputDesc')) .setDesc(isCliBacked ? this.tr('settingTestBackendDescCli') : this.tr('settingTestBackendDescApi'))
.addText((t) => .addButton((b) =>
t.setValue(String(this.plugin.settings.maxDocChars || DEFAULT_SETTINGS.maxDocChars)).onChange((v) => { b.setButtonText(this.tr('settingTestBackendButton')).onClick(async () => {
const n = parseInt(v, 10); b.setDisabled(true);
if (!Number.isNaN(n) && n >= 1000) { b.setButtonText(this.tr('settingTestBackendButtonRunning'));
this.plugin.settings.maxDocChars = n; try {
this.plugin.saveSettingsDebounced(); const result = await testBackend(this.plugin.settings);
new Notice(`${result.slice(0, 180)}`, 8000);
} catch (e: unknown) {
new Notice(this.tr('backendTestFailed', { error: e instanceof Error ? e.message : String(e) }), 10000);
} finally {
b.setButtonText(this.tr('settingTestBackendButton'));
b.setDisabled(false);
} }
}), }),
); );
}
new Setting(containerEl).setName(this.tr('promptHeader')).setHeading(); /* ---------- 2. Reading output (always expanded) ---------- */
private renderReadingOutput(containerEl: HTMLElement) {
new Setting(containerEl).setName(this.tr('sectionReadingOutput')).setHeading();
new Setting(containerEl) new Setting(containerEl)
.setName(this.tr('settingPromptLanguageName')) .setName(this.tr('settingPromptLanguageName'))
@ -296,60 +247,37 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
new Setting(containerEl) new Setting(containerEl)
.setName(this.tr('settingCardRangeName')) .setName(this.tr('settingCardRangeName'))
.setDesc(this.tr('settingCardRangeDesc')) .setDesc(this.tr('settingCardRangeDesc'))
.addText((t) => .addText((textComponent) =>
t textComponent
.setPlaceholder('Min') .setPlaceholder('Min')
.setValue(String(this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards)) .setValue(String(this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards))
.onChange((v) => { .onChange((v) => {
const n = parseInt(v, 10); const trimmed = v.trim();
if (!Number.isNaN(n) && n > 0) { if (trimmed === '') return;
this.plugin.settings.minCards = n; const normalized = normalizeCardCount(trimmed, DEFAULT_SETTINGS.minCards);
if (this.plugin.settings.maxCards < n) this.plugin.settings.maxCards = n; this.plugin.settings.minCards = normalized;
this.plugin.saveSettingsDebounced(); if (this.plugin.settings.maxCards < normalized) this.plugin.settings.maxCards = normalized;
} if (String(normalized) !== trimmed) textComponent.setValue(String(normalized));
this.plugin.saveSettingsDebounced();
}), }),
) )
.addText((t) => .addText((textComponent) =>
t textComponent
.setPlaceholder('Max') .setPlaceholder('Max')
.setValue(String(this.plugin.settings.maxCards || DEFAULT_SETTINGS.maxCards)) .setValue(String(this.plugin.settings.maxCards || DEFAULT_SETTINGS.maxCards))
.onChange((v) => { .onChange((v) => {
const n = parseInt(v, 10); const trimmed = v.trim();
if (!Number.isNaN(n) && n > 0) { if (trimmed === '') return;
this.plugin.settings.maxCards = Math.max(n, this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards); const normalized = normalizeCardCount(trimmed, DEFAULT_SETTINGS.maxCards);
this.plugin.saveSettingsDebounced(); this.plugin.settings.maxCards = Math.max(
normalized,
this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards,
);
if (String(this.plugin.settings.maxCards) !== trimmed) {
textComponent.setValue(String(this.plugin.settings.maxCards));
} }
}),
);
new Setting(containerEl)
.setName(this.tr('settingCustomPromptName'))
.setDesc(this.tr('settingCustomPromptDesc'))
.addTextArea((t) => {
t.inputEl.rows = 8;
return t
.setPlaceholder(this.tr('settingCustomPromptPlaceholder'))
.setValue(this.plugin.settings.customSystemPrompt || '')
.onChange((v) => {
this.plugin.settings.customSystemPrompt = v;
this.plugin.saveSettingsDebounced(); this.plugin.saveSettingsDebounced();
}); }),
});
}
private renderActionsSection(containerEl: HTMLElement, isCliBacked: boolean) {
new Setting(containerEl)
.setName(this.tr('settingTestBackendName'))
.setDesc(isCliBacked ? this.tr('settingTestBackendDescCli') : this.tr('settingTestBackendDescApi'))
.addButton((b) =>
b.setButtonText('Test').onClick(async () => {
try {
const result = await testBackend(this.plugin.settings);
new Notice(`${result.slice(0, 180)}`, 8000);
} catch (e: unknown) {
new Notice(this.tr('backendTestFailed', { error: e instanceof Error ? e.message : String(e) }), 10000);
}
}),
); );
new Setting(containerEl) new Setting(containerEl)
@ -361,12 +289,192 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
this.plugin.saveSettingsDebounced(); this.plugin.saveSettingsDebounced();
}), }),
); );
}
private renderCacheSection(containerEl: HTMLElement) {
new Setting(containerEl).setName(this.tr('cacheHeader')).setHeading();
new Setting(containerEl) new Setting(containerEl)
.setName(this.tr('settingUiLanguageName'))
.setDesc(this.tr('settingUiLanguageDesc'))
.addDropdown((d) => {
for (const [id, label] of Object.entries(UI_LANGUAGES)) {
d.addOption(id, label);
}
return d.setValue(this.plugin.settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage).onChange(async (v) => {
this.plugin.settings.uiLanguage = v;
await this.plugin.saveSettings();
this.display();
});
});
}
/* ---------- 3. Advanced connection (collapsed by default) ---------- */
private renderAdvancedConnection(containerEl: HTMLElement) {
const settings = this.plugin.settings;
const details = this.openCollapsibleSection(
containerEl,
'sectionAdvancedConnection',
shouldOpenAdvancedConnection(settings),
);
new Setting(details)
.setName(this.tr('settingApiFormatName'))
.setDesc(this.tr('settingApiFormatDesc'))
.addDropdown((d) => {
for (const [id, entry] of Object.entries(API_FORMATS)) {
d.addOption(id, entry.label);
}
return d.setValue(getApiFormat(settings)).onChange(async (v) => {
this.plugin.settings.apiFormat = v;
await this.plugin.saveSettings();
this.display();
});
});
new Setting(details)
.setName(this.tr('settingBaseUrlName'))
.setDesc(this.tr('settingBaseUrlDesc'))
.addText((t) => {
const preset = getApiPreset(settings);
t.setPlaceholder(
(settings.apiProvider || '').startsWith('custom-')
? 'https://your-provider.example/v1'
: preset.baseUrl || API_FORMATS[getApiFormat(settings)].defaultBaseUrl,
)
.setValue(settings.apiBaseUrl)
.onChange((v) => {
this.plugin.settings.apiBaseUrl = v.trim();
this.plugin.saveSettingsDebounced();
});
});
new Setting(details)
.setName(this.tr('settingAuthTypeName'))
.setDesc(this.tr('settingAuthTypeDesc'))
.addDropdown((d) => {
for (const [id, label] of Object.entries(API_AUTH_TYPES)) {
d.addOption(id, label);
}
return d.setValue(settings.apiAuthType || 'auto').onChange(async (v) => {
this.plugin.settings.apiAuthType = v;
await this.plugin.saveSettings();
});
});
new Setting(details)
.setName(this.tr('settingHeadersName'))
.setDesc(this.tr('settingHeadersDesc'))
.addTextArea((t) =>
t.setValue(settings.apiHeaders).onChange((v) => {
this.plugin.settings.apiHeaders = v;
this.plugin.saveSettingsDebounced();
}),
);
new Setting(details).setName(this.tr('settingMaxTokensName')).addText((t) =>
t.setValue(String(settings.apiMaxTokens)).onChange((v) => {
const n = parseInt(v, 10);
if (!Number.isNaN(n) && n > 0) {
this.plugin.settings.apiMaxTokens = n;
this.plugin.saveSettingsDebounced();
}
}),
);
// Streaming + timeout merged. We build the timeout in a dedicated wrapper
// and toggle that wrapper's visibility so we never accidentally hide the
// shared controlEl (which would also hide the toggle itself).
const streamingSetting = new Setting(details)
.setName(this.tr('settingStreamingName'))
.setDesc(this.tr('settingStreamingDesc'));
let timeoutWrap: HTMLElement | null = null;
streamingSetting.addToggle((toggle) =>
toggle.setValue(settings.streaming ?? true).onChange(async (v) => {
this.plugin.settings.streaming = v;
await this.plugin.saveSettings();
if (timeoutWrap) timeoutWrap.toggleClass('parallel-reader-hidden', !v);
}),
);
timeoutWrap = streamingSetting.controlEl.createDiv({ cls: 'parallel-reader-timeout-wrap' });
const timeoutInput = timeoutWrap.createEl('input', {
type: 'text',
cls: 'parallel-reader-timeout-input',
});
timeoutInput.placeholder = String(DEFAULT_SETTINGS.streamingTimeoutMs);
timeoutInput.title = this.tr('settingStreamingTimeoutName');
timeoutInput.value = String(settings.streamingTimeoutMs || DEFAULT_SETTINGS.streamingTimeoutMs);
timeoutInput.addEventListener('input', () => {
this.plugin.settings.streamingTimeoutMs = normalizeStreamingTimeoutMs(timeoutInput.value);
this.plugin.saveSettingsDebounced();
});
timeoutWrap.toggleClass('parallel-reader-hidden', !(settings.streaming ?? true));
}
private renderAdvancedConnectionCli(containerEl: HTMLElement) {
const settings = this.plugin.settings;
const userChangedTimeout = !!(settings.cliTimeoutMs && settings.cliTimeoutMs !== DEFAULT_SETTINGS.cliTimeoutMs);
const details = this.openCollapsibleSection(containerEl, 'sectionAdvancedConnection', userChangedTimeout);
new Setting(details)
.setName(this.tr('settingCliTimeoutName'))
.setDesc(this.tr('settingCliTimeoutDesc'))
.addText((t) =>
t
.setPlaceholder(String(DEFAULT_SETTINGS.cliTimeoutMs))
.setValue(String(this.plugin.settings.cliTimeoutMs || DEFAULT_SETTINGS.cliTimeoutMs))
.onChange((v) => {
this.plugin.settings.cliTimeoutMs = normalizeCliTimeoutMs(v);
this.plugin.saveSettingsDebounced();
}),
);
}
/* ---------- 4. Advanced prompt (collapsed by default) ---------- */
private renderAdvancedPrompt(containerEl: HTMLElement) {
const settings = this.plugin.settings;
const userHasCustomPrompt = !!(settings.customSystemPrompt || '').trim();
const userHasCustomMaxInput = settings.maxDocChars && settings.maxDocChars !== DEFAULT_SETTINGS.maxDocChars;
const details = this.openCollapsibleSection(
containerEl,
'sectionAdvancedPrompt',
!!(userHasCustomPrompt || userHasCustomMaxInput),
);
new Setting(details)
.setName(this.tr('settingMaxInputName'))
.setDesc(this.tr('settingMaxInputDesc'))
.addText((t) =>
t.setValue(String(settings.maxDocChars || DEFAULT_SETTINGS.maxDocChars)).onChange((v) => {
const n = parseInt(v, 10);
if (!Number.isNaN(n) && n >= 1000) {
this.plugin.settings.maxDocChars = n;
this.plugin.saveSettingsDebounced();
}
}),
);
new Setting(details)
.setName(this.tr('settingCustomPromptName'))
.setDesc(this.tr('settingCustomPromptDesc'))
.addTextArea((t) => {
t.inputEl.rows = 8;
return t
.setPlaceholder(this.tr('settingCustomPromptPlaceholder'))
.setValue(settings.customSystemPrompt || '')
.onChange((v) => {
this.plugin.settings.customSystemPrompt = v;
this.plugin.saveSettingsDebounced();
});
});
}
/* ---------- 5. Cache & maintenance (collapsed by default) ---------- */
private renderCacheMaintenance(containerEl: HTMLElement) {
const details = this.openCollapsibleSection(containerEl, 'sectionCacheMaintenance', false);
new Setting(details)
.setName(this.tr('settingMaxCacheName')) .setName(this.tr('settingMaxCacheName'))
.setDesc(this.tr('settingMaxCacheDesc')) .setDesc(this.tr('settingMaxCacheDesc'))
.addText((t) => { .addText((t) => {
@ -391,7 +499,7 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
}); });
const cacheCount = Object.keys(this.plugin.cache).length; const cacheCount = Object.keys(this.plugin.cache).length;
new Setting(containerEl) new Setting(details)
.setName(this.tr('cachedNotesName', { count: cacheCount })) .setName(this.tr('cachedNotesName', { count: cacheCount }))
.setDesc(this.tr('cachedNotesDesc')) .setDesc(this.tr('cachedNotesDesc'))
.addButton((b) => .addButton((b) =>
@ -406,4 +514,15 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
}), }),
); );
} }
/* ---------- helpers ---------- */
/** Create a `<details>` collapsible section with translated `<summary>` and
* return the inner element to render Settings into. */
private openCollapsibleSection(parent: HTMLElement, summaryKey: string, openByDefault: boolean): HTMLElement {
const details = parent.createEl('details', { cls: 'parallel-reader-section' });
if (openByDefault) details.setAttr('open', '');
details.createEl('summary', { text: this.tr(summaryKey), cls: 'parallel-reader-section-summary' });
return details;
}
} }

View file

@ -8,19 +8,31 @@ import type { ApiFormat, ApiProviderPreset, CacheEntry, PluginSettings } from '.
export { API_PROVIDER_PRESETS } from './provider-presets'; export { API_PROVIDER_PRESETS } from './provider-presets';
export const MAX_DOC_CHARS = 100000; export const MAX_DOC_CHARS = 100000;
export const PROMPT_VERSION = 2; const PROMPT_VERSION = 2;
export const CACHE_SCHEMA_VERSION = 2; export const CACHE_SCHEMA_VERSION = 2;
export const DEFAULT_MAX_CACHE_ENTRIES = 100; export const DEFAULT_MAX_CACHE_ENTRIES = 100;
export const MIN_STREAMING_TIMEOUT_MS = 1000; const DEFAULT_CLI_TIMEOUT_MS = 300000;
const MIN_STREAMING_TIMEOUT_MS = 1000;
const MIN_CLI_TIMEOUT_MS = 1000;
export const PROMPT_LANGUAGES = { export const PROMPT_LANGUAGES = {
auto: 'Auto-detect',
zh: '中文', zh: '中文',
en: 'English', en: 'English',
auto: 'Auto-detect', ja: '日本語',
ko: '한국어',
fr: 'Français',
de: 'Deutsch',
es: 'Español',
}; };
export const UI_LANGUAGES = { export const UI_LANGUAGES = {
auto: 'Auto', auto: 'Auto',
zh: '中文', zh: '中文',
en: 'English', en: 'English',
ja: '日本語',
ko: '한국어',
fr: 'Français',
de: 'Deutsch',
es: 'Español',
}; };
export const DEFAULT_SETTINGS: PluginSettings = { export const DEFAULT_SETTINGS: PluginSettings = {
@ -37,13 +49,16 @@ export const DEFAULT_SETTINGS: PluginSettings = {
apiMaxTokens: 4096, apiMaxTokens: 4096,
maxDocChars: MAX_DOC_CHARS, maxDocChars: MAX_DOC_CHARS,
maxCacheEntries: DEFAULT_MAX_CACHE_ENTRIES, maxCacheEntries: DEFAULT_MAX_CACHE_ENTRIES,
promptLanguage: 'zh', // 'auto' = match the source document's main language, so a new user reading an
// English (or any non-Chinese) note gets summaries in that language by default
// instead of forced Chinese. Existing users keep whatever they have persisted.
promptLanguage: 'auto',
minCards: 5, minCards: 5,
maxCards: 15, maxCards: 15,
customSystemPrompt: '', customSystemPrompt: '',
model: 'claude-sonnet-4-6', model: 'claude-sonnet-4-6',
exportFolder: 'Reading/Articles', exportFolder: 'Reading/Articles',
cliTimeoutMs: 120000, cliTimeoutMs: DEFAULT_CLI_TIMEOUT_MS,
streaming: true, streaming: true,
streamingTimeoutMs: 120000, streamingTimeoutMs: 120000,
}; };
@ -180,6 +195,8 @@ export function applyApiProviderPreset(settings: Readonly<PluginSettings>, provi
apiBaseUrl: preset.baseUrl, apiBaseUrl: preset.baseUrl,
apiAuthType: preset.authType || 'auto', apiAuthType: preset.authType || 'auto',
apiKeyEnvVar: preset.envVar || '', apiKeyEnvVar: preset.envVar || '',
apiKey: '',
apiHeaders: '',
...(shouldSwapModel ? { model: preset.model || '' } : {}), ...(shouldSwapModel ? { model: preset.model || '' } : {}),
}; };
} }
@ -211,6 +228,7 @@ export function normalizeSettings(settings: Readonly<PluginSettings>): PluginSet
out.maxCards = normalizeCardCount(out.maxCards, DEFAULT_SETTINGS.maxCards); out.maxCards = normalizeCardCount(out.maxCards, DEFAULT_SETTINGS.maxCards);
if (out.maxCards < out.minCards) out.maxCards = out.minCards; if (out.maxCards < out.minCards) out.maxCards = out.minCards;
out.streamingTimeoutMs = normalizeStreamingTimeoutMs(out.streamingTimeoutMs); out.streamingTimeoutMs = normalizeStreamingTimeoutMs(out.streamingTimeoutMs);
out.cliTimeoutMs = normalizeCliTimeoutMs(out.cliTimeoutMs);
if (typeof out.customSystemPrompt !== 'string') out.customSystemPrompt = ''; if (typeof out.customSystemPrompt !== 'string') out.customSystemPrompt = '';
return out; return out;
} }
@ -233,6 +251,12 @@ export function normalizeStreamingTimeoutMs(value: unknown): number {
return n; return n;
} }
export function normalizeCliTimeoutMs(value: unknown): number {
const n = Math.floor(Number(value));
if (!Number.isFinite(n) || n < MIN_CLI_TIMEOUT_MS) return DEFAULT_SETTINGS.cliTimeoutMs;
return n;
}
function cacheEntryTime(entry: CacheEntry): number { function cacheEntryTime(entry: CacheEntry): number {
const value = entry && (entry.lastAccessedAt || entry.generatedAt || entry.updatedAt); const value = entry && (entry.lastAccessedAt || entry.generatedAt || entry.updatedAt);
const timestamp = Date.parse(value || ''); const timestamp = Date.parse(value || '');
@ -257,7 +281,24 @@ export function pruneCacheEntries(cache: Record<string, CacheEntry>, maxEntries:
return removed; return removed;
} }
// Memoize by settings object identity. The plugin replaces this.settings with
// a fresh object only on load/save, so the same reference always yields the
// same fingerprint — this collapses the SHA-1 + stableStringify work done on
// every file-open / cache check down to once per settings change.
const fingerprintCache = new WeakMap<object, string>();
export function generationFingerprint(settings: PluginSettings): string { export function generationFingerprint(settings: PluginSettings): string {
const cacheKey = settings as unknown as object;
if (cacheKey) {
const cached = fingerprintCache.get(cacheKey);
if (cached !== undefined) return cached;
}
const result = computeGenerationFingerprint(settings);
if (cacheKey) fingerprintCache.set(cacheKey, result);
return result;
}
function computeGenerationFingerprint(settings: PluginSettings): string {
const normalized = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settings || {})); const normalized = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settings || {}));
const apiBackend = isApiBackend(normalized.backend); const apiBackend = isApiBackend(normalized.backend);
const preset = getApiPreset(normalized); const preset = getApiPreset(normalized);
@ -275,7 +316,9 @@ export function generationFingerprint(settings: PluginSettings): string {
maxCards: normalized.maxCards, maxCards: normalized.maxCards,
customSystemPromptHash: hashContent(normalized.customSystemPrompt || ''), customSystemPromptHash: hashContent(normalized.customSystemPrompt || ''),
backend: normalized.backend, backend: normalized.backend,
model: normalized.model, // Codex backend ignores settings.model (uses its own config); excluding it from
// the fingerprint avoids spurious cache invalidation when the user edits model.
model: normalized.backend === 'codex' ? '' : normalized.model,
apiProvider: apiBackend ? normalized.apiProvider : '', apiProvider: apiBackend ? normalized.apiProvider : '',
apiFormat: apiBackend ? format : '', apiFormat: apiBackend ? format : '',
apiBaseUrl, apiBaseUrl,

View file

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import { translate } from './i18n'; import { translate } from './i18n';
import type { RequestUrlFunction } from './provider-request';
import type { PluginSettings } from './types'; import type { PluginSettings } from './types';
/** /**
@ -25,6 +26,32 @@ function anthropicDelta(json: Record<string, unknown>): string {
return ''; return '';
} }
/**
* Detect a provider error delivered as an SSE payload inside an HTTP 200 stream.
* These bypass the `response.status >= 400` guard, so without this they would be
* extracted as empty deltas and later misreported as "non-JSON LLM output".
* Anthropic: { type: 'error', error: { type, message } }
* OpenAI-compatible: { error: { message, type, code } }
* Returns a human-readable error message for an error payload, or null otherwise.
*/
export function streamErrorMessage(json: Record<string, unknown>): string | null {
const messageFromError = (value: unknown): string | null => {
if (!value || typeof value !== 'object') return null;
const err = value as { message?: unknown; type?: unknown };
if (typeof err.message === 'string' && err.message) return err.message;
if (typeof err.type === 'string' && err.type) return err.type;
return null;
};
// Anthropic: an explicit error event is an error even if its details are sparse.
if (json.type === 'error') {
return messageFromError(json.error) ?? 'Provider returned a streaming error';
}
// OpenAI-compatible: { error: { message, type, code } }. Only treat it as an error
// when it actually carries a message/type, so a stray empty `error: {}` (or a
// code-only object) in an otherwise-normal chunk does not abort the stream.
return messageFromError(json.error);
}
export type DeltaExtractor = (json: Record<string, unknown>) => string; export type DeltaExtractor = (json: Record<string, unknown>) => string;
export function deltaExtractorForFormat(format: string): DeltaExtractor | null { export function deltaExtractorForFormat(format: string): DeltaExtractor | null {
@ -60,13 +87,18 @@ export function parseSseBuffer(buffer: string, extractDelta: DeltaExtractor): {
const data = dataLines.join('\n'); const data = dataLines.join('\n');
if (data.trim() === '[DONE]') continue; if (data.trim() === '[DONE]') continue;
let json: Record<string, unknown>;
try { try {
const json = JSON.parse(data) as Record<string, unknown>; json = JSON.parse(data) as Record<string, unknown>;
const delta = extractDelta(json); } catch {
if (delta) deltas.push(delta); continue; // skip non-JSON SSE lines (keep-alives, partial frames)
} catch (_) {
// skip non-JSON SSE lines
} }
// Provider errors arrive as a 200-status SSE payload — surface them instead of
// swallowing them, so a transient overload/quota error is not misreported downstream.
const errorMessage = streamErrorMessage(json);
if (errorMessage) throw new Error(errorMessage);
const delta = extractDelta(json);
if (delta) deltas.push(delta);
} }
return { deltas, rest }; return { deltas, rest };
} }
@ -76,60 +108,52 @@ export interface StreamProgress {
done: boolean; done: boolean;
} }
async function doStreamingFetch( async function doStreamingRequestUrl(
requestUrlImpl: RequestUrlFunction,
url: string, url: string,
headers: Record<string, string>, headers: Record<string, string>,
body: unknown, body: unknown,
extractDelta: DeltaExtractor, extractDelta: DeltaExtractor,
onProgress: ((progress: StreamProgress) => void) | undefined, onProgress: ((progress: StreamProgress) => void) | undefined,
signal: AbortSignal,
settings: PluginSettings | null | undefined, settings: PluginSettings | null | undefined,
): Promise<string> { ): Promise<string> {
const response = await globalThis.fetch(url, { let response: Awaited<ReturnType<RequestUrlFunction>>;
method: 'POST', try {
headers, response = await requestUrlImpl({
body: JSON.stringify(body), url,
signal, method: 'POST',
}); headers,
body: JSON.stringify(body),
if (!response.ok) { throw: false,
const text = await response.text(); });
} catch (e: unknown) {
throw new Error( throw new Error(
translate(settings || null, 'errorProviderApiStatus', { translate(settings || null, 'errorProviderRequestFailed', {
label: 'Streaming', label: 'Streaming',
status: response.status, error: e instanceof Error ? e.message : String(e),
excerpt: text.slice(0, 500),
}), }),
); );
} }
if (!response.body) { if (response.status >= 400) {
throw new Error('Response has no body for streaming'); throw new Error(
translate(settings || null, 'errorProviderApiStatus', {
label: 'Streaming',
status: response.status,
excerpt: (response.text || '').slice(0, 500),
}),
);
} }
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = ''; let accumulated = '';
let buffer = ''; const text = response.text || '';
const buffer = text.endsWith('\n\n') ? text : `${text}\n\n`;
try { const result = parseSseBuffer(buffer, extractDelta);
while (true) { for (const delta of result.deltas) {
const { done, value } = await reader.read(); accumulated += delta;
if (done) break; }
if (result.deltas.length > 0) {
buffer += decoder.decode(value, { stream: true }); onProgress?.({ accumulated, done: false });
const result = parseSseBuffer(buffer, extractDelta);
buffer = result.rest;
for (const delta of result.deltas) {
accumulated += delta;
}
if (result.deltas.length > 0) {
onProgress?.({ accumulated, done: false });
}
}
} finally {
reader.releaseLock();
} }
onProgress?.({ accumulated, done: true }); onProgress?.({ accumulated, done: true });
@ -137,11 +161,12 @@ async function doStreamingFetch(
} }
/** /**
* Perform a streaming fetch with SSE parsing and configurable timeout. * Perform an Obsidian requestUrl call that asks providers for SSE output and parses
* Uses the native Fetch API (available in Electron/Obsidian). * the complete response text. requestUrl is one-shot, so progress arrives after
* Returns the full accumulated text when done. * the HTTP request completes rather than per network chunk.
*/ */
export async function streamingFetch( export async function streamingRequestUrl(
requestUrlImpl: RequestUrlFunction,
url: string, url: string,
headers: Record<string, string>, headers: Record<string, string>,
body: unknown, body: unknown,
@ -150,35 +175,44 @@ export async function streamingFetch(
signal?: AbortSignal, signal?: AbortSignal,
settings?: PluginSettings | null, settings?: PluginSettings | null,
): Promise<string> { ): Promise<string> {
if (typeof globalThis.fetch !== 'function') {
throw new Error('Streaming requires fetch API');
}
const timeoutMs = settings?.streamingTimeoutMs ?? 120000; const timeoutMs = settings?.streamingTimeoutMs ?? 120000;
const timeoutController = new AbortController();
let abortListener: (() => void) | null = null; let abortListener: (() => void) | null = null;
let abortPromise: Promise<never> | null = null;
if (signal) { if (signal) {
abortListener = () => timeoutController.abort(); if (signal.aborted) throw new Error('Streaming request aborted');
signal.addEventListener('abort', abortListener, { once: true }); abortPromise = (async () => {
if (signal.aborted) timeoutController.abort(); await new Promise<void>((resolve) => {
abortListener = resolve;
signal.addEventListener('abort', abortListener, { once: true });
});
throw new Error('Streaming request aborted');
})();
} }
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: number | null = null;
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = (async () => {
timeoutId = setTimeout(() => { await new Promise<void>((resolve) => {
timeoutController.abort(); timeoutId = activeWindow.setTimeout(resolve, timeoutMs);
reject(new Error(`Streaming timed out after ${timeoutMs}ms`)); });
}, timeoutMs); throw new Error(`Streaming timed out after ${timeoutMs}ms`);
}); })();
try { try {
return await Promise.race([ const requestPromise = doStreamingRequestUrl(
doStreamingFetch(url, headers, body, extractDelta, onProgress, timeoutController.signal, settings), requestUrlImpl,
timeoutPromise, url,
]); headers,
body,
extractDelta,
onProgress,
settings,
);
return await Promise.race(
abortPromise ? [requestPromise, timeoutPromise, abortPromise] : [requestPromise, timeoutPromise],
);
} finally { } finally {
if (timeoutId !== null) clearTimeout(timeoutId); if (timeoutId !== null) activeWindow.clearTimeout(timeoutId);
if (signal && abortListener) signal.removeEventListener('abort', abortListener); if (signal && abortListener) signal.removeEventListener('abort', abortListener);
} }
} }

View file

@ -1,6 +1,8 @@
'use strict'; 'use strict';
export { findLineForAnchor } from './anchor'; export { default as ParallelReaderPlugin } from '../main';
export { buildLineOffsets, findLineForAnchor } from './anchor';
export { testBackend } from './backend-test';
export { export {
batchProgressVars, batchProgressVars,
createBatchRunState, createBatchRunState,
@ -19,7 +21,7 @@ export {
export { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './cache'; export { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './cache';
export { CacheManager } from './cache-manager'; export { CacheManager } from './cache-manager';
export { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards'; export { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards';
export { resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli'; export { CliProcessError, resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli';
export { cancellationNoticeKey, summarizeDocument } from './generation'; export { cancellationNoticeKey, summarizeDocument } from './generation';
export { export {
classifyGenerationError, classifyGenerationError,
@ -31,6 +33,14 @@ export { translate } from './i18n';
export { cardsToMarkdown } from './markdown'; export { cardsToMarkdown } from './markdown';
export { activeSectionLine, nextCardIndex } from './navigation'; export { activeSectionLine, nextCardIndex } from './navigation';
export { buildPrompts } from './prompt'; export { buildPrompts } from './prompt';
export {
endpointUrl,
ProviderApiError,
requestJsonBody,
requestJsonBodyWithStructuredFallback,
responseJson,
shouldRetryWithoutStructuredOutput,
} from './provider-request';
export { export {
buildAnthropicMessagesBody, buildAnthropicMessagesBody,
buildGeminiBody, buildGeminiBody,
@ -44,15 +54,19 @@ export {
export { collectJsonObjectCandidates, extractJson, normalizeCardsPayload, repairTruncatedCardsJson } from './schema'; export { collectJsonObjectCandidates, extractJson, normalizeCardsPayload, repairTruncatedCardsJson } from './schema';
export { createRafThrottledHandler, visibleTopProbeY } from './scroll'; export { createRafThrottledHandler, visibleTopProbeY } from './scroll';
export { export {
applyApiProviderPreset,
CACHE_SCHEMA_VERSION, CACHE_SCHEMA_VERSION,
cacheEntryMatches, cacheEntryMatches,
generationFingerprint, generationFingerprint,
getApiBaseUrl, getApiBaseUrl,
modelForApi, modelForApi,
normalizeCardCount,
normalizeCliTimeoutMs,
normalizeSettings, normalizeSettings,
normalizeStreamingTimeoutMs, normalizeStreamingTimeoutMs,
pruneCacheEntries, pruneCacheEntries,
} from './settings'; } from './settings';
export { deltaExtractorForFormat, parseSseBuffer } from './streaming'; export { deltaExtractorForFormat, parseSseBuffer, streamErrorMessage } from './streaming';
export { addIconButton, addTextButton, copyToClipboard } from './ui-helpers'; export { addIconButton, addTextButton, copyToClipboard } from './ui-helpers';
export { folderPathsForTarget } from './vault'; export { folderPathsForTarget } from './vault';
export { ParallelReaderView } from './view';

View file

@ -37,11 +37,6 @@ export interface CacheEntry {
updatedAt?: string; updatedAt?: string;
} }
export interface CacheFile {
version: number;
entries: Record<string, CacheEntry>;
}
/* ---------- Settings types ---------- */ /* ---------- Settings types ---------- */
export interface PluginSettings { export interface PluginSettings {
@ -101,7 +96,17 @@ export type GenerationPhase =
| 'done' | 'done'
| 'cancelled'; | 'cancelled';
export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'schema' | 'config' | 'cancelled' | 'unknown'; export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'network' | 'schema' | 'config' | 'cancelled' | 'unknown';
export interface RunForFileOptions {
rethrowErrors?: boolean;
/** Skip ensureView+revealLeaf; only update view if it's already showing this file (used by batch). */
silentView?: boolean;
/** Skip the "you have edited cards" confirm dialog (used by unattended batch). */
skipEditConfirm?: boolean;
}
export type RunForFileResult = 'generated' | 'cached' | 'cancelled' | 'already-running' | 'empty' | 'error' | 'no-view';
/* ---------- Prompt types ---------- */ /* ---------- Prompt types ---------- */
@ -154,9 +159,18 @@ export interface PluginHost {
cache: Record<string, CacheEntry>; cache: Record<string, CacheEntry>;
manifest: PluginManifest; manifest: PluginManifest;
t(key: string, vars?: Record<string, string | number>): string; t(key: string, vars?: Record<string, string | number>): string;
/** Open the plugin's settings tab (best-effort; no-op if the API is unavailable). */
openSettings(): void;
/** True if a usable credential is configured for the current backend (API key, env var, or keyless local provider). */
isCredentialConfigured(): boolean;
isGeneratingFile(file: TFile | null): boolean; isGeneratingFile(file: TFile | null): boolean;
cancelGenerationForFile(file: TFile | null): boolean; cancelGenerationForFile(file: TFile | null): boolean;
runForFile(file: TFile | null, force: boolean): Promise<void>; runForFile(
file: TFile | null,
force: boolean,
options?: RunForFileOptions,
preloadedContent?: string,
): Promise<RunForFileResult>;
copyCurrentViewMarkdown(): Promise<void>; copyCurrentViewMarkdown(): Promise<void>;
scrollEditorToLine(line: number, file: TFile | null): Promise<void>; scrollEditorToLine(line: number, file: TFile | null): Promise<void>;
cacheReplaceCards(filePath: string, cards: ResolvedCard[]): Promise<boolean>; cacheReplaceCards(filePath: string, cards: ResolvedCard[]): Promise<boolean>;

View file

@ -14,7 +14,11 @@ export function folderPathsForTarget(folderPath: string): string[] {
const normalized = normalizeVaultPath(folderPath); const normalized = normalizeVaultPath(folderPath);
if (!normalized) return []; if (!normalized) return [];
const parts = normalized.split('/'); const parts = normalized.split('/');
return parts.map((_, idx) => parts.slice(0, idx + 1).join('/')); const folders: string[] = [];
for (let idx = 0; idx < parts.length; idx++) {
folders.push(parts.slice(0, idx + 1).join('/'));
}
return folders;
} }
export async function ensureVaultFolder(app: App, folderPath: string) { export async function ensureVaultFolder(app: App, folderPath: string) {

View file

@ -3,7 +3,7 @@
import { ItemView, MarkdownRenderer, Menu, Notice, TFile, type WorkspaceLeaf } from 'obsidian'; import { ItemView, MarkdownRenderer, Menu, Notice, TFile, type WorkspaceLeaf } from 'obsidian';
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards'; import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards';
import { cardsToMarkdown, cardToMarkdown, cardToPlain } from './markdown'; import { cardsToMarkdown, cardToMarkdown, cardToPlain } from './markdown';
import { CardEditModal } from './modal'; import { CardEditModal, confirmExportOverwrite } from './modal';
import { activeSectionLine, nextCardIndex } from './navigation'; import { activeSectionLine, nextCardIndex } from './navigation';
import type { CardPatch, PluginHost, ResolvedCard } from './types'; import type { CardPatch, PluginHost, ResolvedCard } from './types';
import { addIconButton, addTextButton, copyToClipboard } from './ui-helpers'; import { addIconButton, addTextButton, copyToClipboard } from './ui-helpers';
@ -20,6 +20,8 @@ export class ParallelReaderView extends ItemView {
stale = false; stale = false;
loadingMessage = ''; loadingMessage = '';
errorMessage = ''; errorMessage = '';
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
private keydownTarget: Element | null = null;
constructor(leaf: WorkspaceLeaf, plugin: PluginHost) { constructor(leaf: WorkspaceLeaf, plugin: PluginHost) {
super(leaf); super(leaf);
@ -45,13 +47,20 @@ export class ParallelReaderView extends ItemView {
container.empty(); container.empty();
container.addClass('parallel-reader-container'); container.addClass('parallel-reader-container');
container.setAttr('tabindex', '0'); container.setAttr('tabindex', '0');
container.addEventListener('keydown', (e) => this.handleKeydown(e as KeyboardEvent)); this.keydownHandler = (e) => this.handleKeydown(e);
this.keydownTarget = container;
container.addEventListener('keydown', this.keydownHandler as EventListener);
this.renderEmpty(); this.renderEmpty();
this.focusSummaryPane(); this.focusSummaryPane();
return Promise.resolve(); return Promise.resolve();
} }
onClose() { onClose() {
if (this.keydownHandler && this.keydownTarget) {
this.keydownTarget.removeEventListener('keydown', this.keydownHandler as EventListener);
}
this.keydownHandler = null;
this.keydownTarget = null;
return Promise.resolve(); return Promise.resolve();
} }
@ -67,6 +76,18 @@ export class ParallelReaderView extends ItemView {
hint.createEl('h3', { text: this.plugin.t('appTitle') }); hint.createEl('h3', { text: this.plugin.t('appTitle') });
hint.createEl('p', { text: this.plugin.t('emptyOpenNote') }); hint.createEl('p', { text: this.plugin.t('emptyOpenNote') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') }); hint.createEl('code', { text: this.plugin.t('commandGenerate') });
this.appendSetupNudge(hint);
}
/**
* When no credential is configured, append a "set up your AI provider" call-to-action
* so a first-run user does not hit a dead-end (Generate immediate API-key error).
*/
private appendSetupNudge(parent: HTMLElement): boolean {
if (this.plugin.isCredentialConfigured()) return false;
parent.createEl('p', { cls: 'parallel-reader-setup-hint', text: this.plugin.t('emptyNeedsSetup') });
addTextButton(parent, 'settings', this.plugin.t('actionSetupProvider'), () => this.plugin.openSettings());
return true;
} }
focusSummaryPane() { focusSummaryPane() {
@ -110,7 +131,7 @@ export class ParallelReaderView extends ItemView {
container.empty(); container.empty();
const header = container.createDiv({ cls: 'parallel-reader-header' }); const header = container.createDiv({ cls: 'parallel-reader-header' });
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' }); const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
headerRow.createEl('div', { text: file.basename, cls: 'parallel-reader-title' }); headerRow.createDiv({ text: file.basename, cls: 'parallel-reader-title' });
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' }); const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => { addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => {
this.plugin.cancelGenerationForFile(file); this.plugin.cancelGenerationForFile(file);
@ -120,7 +141,7 @@ export class ParallelReaderView extends ItemView {
cls: 'parallel-reader-state parallel-reader-loading parallel-reader-streaming-preview', cls: 'parallel-reader-state parallel-reader-loading parallel-reader-streaming-preview',
}); });
state.createDiv({ cls: 'parallel-reader-spinner' }); state.createDiv({ cls: 'parallel-reader-spinner' });
const titleEl = state.createEl('div', { cls: 'parallel-reader-state-title' }); const titleEl = state.createDiv({ cls: 'parallel-reader-state-title' });
titleEl.createSpan({ text: this.plugin.t('loadingGenerating') + ' ' }); titleEl.createSpan({ text: this.plugin.t('loadingGenerating') + ' ' });
titleEl.createSpan({ cls: 'parallel-reader-stream-counter', text: `${text.length} chars` }); titleEl.createSpan({ cls: 'parallel-reader-stream-counter', text: `${text.length} chars` });
const pre = state.createEl('pre', { cls: 'parallel-reader-stream-text' }); const pre = state.createEl('pre', { cls: 'parallel-reader-stream-text' });
@ -148,9 +169,10 @@ export class ParallelReaderView extends ItemView {
hint.createEl('h3', { text: file.basename }); hint.createEl('h3', { text: file.basename });
hint.createEl('p', { text: this.plugin.t('emptyNoCache') }); hint.createEl('p', { text: this.plugin.t('emptyNoCache') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') }); hint.createEl('code', { text: this.plugin.t('commandGenerate') });
this.appendSetupNudge(hint);
addTextButton(hint, null, this.plugin.t('actionGenerate'), () => { addTextButton(hint, null, this.plugin.t('actionGenerate'), () => {
if (this.plugin.isGeneratingFile(file)) return; if (this.plugin.isGeneratingFile(file)) return;
this.plugin.runForFile(file, false); void this.plugin.runForFile(file, false);
}); });
} }
@ -174,7 +196,7 @@ export class ParallelReaderView extends ItemView {
private renderHeader(container: Element) { private renderHeader(container: Element) {
const header = container.createDiv({ cls: 'parallel-reader-header' }); const header = container.createDiv({ cls: 'parallel-reader-header' });
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' }); const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
headerRow.createEl('div', { text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' }); headerRow.createDiv({ text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' });
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' }); const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
if (this.sourceFile) { if (this.sourceFile) {
if (this.plugin.isGeneratingFile(this.sourceFile)) { if (this.plugin.isGeneratingFile(this.sourceFile)) {
@ -182,12 +204,15 @@ export class ParallelReaderView extends ItemView {
this.plugin.cancelGenerationForFile(this.sourceFile); this.plugin.cancelGenerationForFile(this.sourceFile);
}); });
} else { } else {
addIconButton(actions, 'refresh-cw', this.plugin.t('actionRegenerate'), () => addIconButton(
this.plugin.runForFile(this.sourceFile, true), actions,
'refresh-cw',
this.plugin.t('actionRegenerate'),
() => void this.plugin.runForFile(this.sourceFile, true),
); );
} }
addIconButton(actions, 'copy', this.plugin.t('actionCopyAll'), () => this.plugin.copyCurrentViewMarkdown()); addIconButton(actions, 'copy', this.plugin.t('actionCopyAll'), () => void this.plugin.copyCurrentViewMarkdown());
addIconButton(actions, 'download', this.plugin.t('actionExport'), () => this.exportToVault()); addIconButton(actions, 'download', this.plugin.t('actionExport'), () => void this.exportToVault());
} }
} }
@ -198,7 +223,7 @@ export class ParallelReaderView extends ItemView {
banner, banner,
'refresh-cw', 'refresh-cw',
this.plugin.t('actionRegenerate'), this.plugin.t('actionRegenerate'),
() => this.plugin.runForFile(this.sourceFile, true), () => void this.plugin.runForFile(this.sourceFile, true),
'parallel-reader-stale-button', 'parallel-reader-stale-button',
); );
} }
@ -206,14 +231,14 @@ export class ParallelReaderView extends ItemView {
private renderLoadingState(container: Element) { private renderLoadingState(container: Element) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-loading' }); const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-loading' });
state.createDiv({ cls: 'parallel-reader-spinner' }); state.createDiv({ cls: 'parallel-reader-spinner' });
state.createEl('div', { text: this.loadingMessage, cls: 'parallel-reader-state-title' }); state.createDiv({ text: this.loadingMessage, cls: 'parallel-reader-state-title' });
state.createEl('div', { text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' }); state.createDiv({ text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
} }
private renderErrorState(container: Element) { private renderErrorState(container: Element) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' }); const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' });
state.createEl('div', { text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' }); state.createDiv({ text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
state.createEl('div', { state.createDiv({
text: this.errorMessage, text: this.errorMessage,
cls: 'parallel-reader-state-subtitle parallel-reader-selectable', cls: 'parallel-reader-state-subtitle parallel-reader-selectable',
}); });
@ -222,14 +247,14 @@ export class ParallelReaderView extends ItemView {
actions, actions,
'refresh-cw', 'refresh-cw',
this.plugin.t('actionRegenerate'), this.plugin.t('actionRegenerate'),
() => this.plugin.runForFile(this.sourceFile, true), () => void this.plugin.runForFile(this.sourceFile, true),
'parallel-reader-text-button', 'parallel-reader-text-button',
); );
addTextButton( addTextButton(
actions, actions,
'copy', 'copy',
this.plugin.t('actionCopyError'), this.plugin.t('actionCopyError'),
() => copyToClipboard(this.errorMessage, this.plugin.t('actionCopyError')), () => void copyToClipboard(this.errorMessage, this.plugin.t('actionCopyError')),
'parallel-reader-text-button', 'parallel-reader-text-button',
); );
} }
@ -251,14 +276,14 @@ export class ParallelReaderView extends ItemView {
card.dataset.idx = String(i); card.dataset.idx = String(i);
if (s.startLine < 0) card.addClass('parallel-reader-card-unanchored'); if (s.startLine < 0) card.addClass('parallel-reader-card-unanchored');
const title = card.createEl('div', { cls: 'parallel-reader-card-title' }); const title = card.createDiv({ cls: 'parallel-reader-card-title' });
title.createSpan({ text: s.title }); title.createSpan({ text: s.title });
if (s.startLine < 0) { if (s.startLine < 0) {
title.createEl('span', { text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') }); title.createSpan({ text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') });
} }
if (s.gist) { if (s.gist) {
const gistEl = card.createEl('div', { cls: 'parallel-reader-gist' }); const gistEl = card.createDiv({ cls: 'parallel-reader-gist' });
MarkdownRenderer.render(this.app, s.gist, gistEl, sourcePath, this).catch(() => { MarkdownRenderer.render(this.app, s.gist, gistEl, sourcePath, this).catch(() => {
gistEl.setText(s.gist); gistEl.setText(s.gist);
}); });
@ -266,13 +291,13 @@ export class ParallelReaderView extends ItemView {
const bs = s.bullets || []; const bs = s.bullets || [];
if (bs.length > 0) { if (bs.length > 0) {
const bulletsEl = card.createEl('div', { cls: 'parallel-reader-bullets-md' }); const bulletsEl = card.createDiv({ cls: 'parallel-reader-bullets-md' });
const md = bs.map((b) => `- ${b}`).join('\n'); const md = bs.map((b) => `- ${b}`).join('\n');
MarkdownRenderer.render(this.app, md, bulletsEl, sourcePath, this).catch(() => { MarkdownRenderer.render(this.app, md, bulletsEl, sourcePath, this).catch(() => {
bulletsEl.setText(md); bulletsEl.setText(md);
}); });
} else if (!s.gist) { } else if (!s.gist) {
card.createEl('div', { cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') }); card.createDiv({ cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') });
} }
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
@ -297,20 +322,20 @@ export class ParallelReaderView extends ItemView {
it it
.setTitle(this.plugin.t('menuCopyMarkdown')) .setTitle(this.plugin.t('menuCopyMarkdown'))
.setIcon('copy') .setIcon('copy')
.onClick(() => copyToClipboard(cardToMarkdown(s), this.plugin.t('copiedMarkdown'))), .onClick(() => void copyToClipboard(cardToMarkdown(s), this.plugin.t('copiedMarkdown'))),
); );
menu.addItem((it) => menu.addItem((it) =>
it it
.setTitle(this.plugin.t('menuCopyPlain')) .setTitle(this.plugin.t('menuCopyPlain'))
.setIcon('clipboard-copy') .setIcon('clipboard-copy')
.onClick(() => copyToClipboard(cardToPlain(s), this.plugin.t('copiedPlain'))), .onClick(() => void copyToClipboard(cardToPlain(s), this.plugin.t('copiedPlain'))),
); );
if (s.anchor) { if (s.anchor) {
menu.addItem((it) => menu.addItem((it) =>
it it
.setTitle(this.plugin.t('menuCopyAnchor')) .setTitle(this.plugin.t('menuCopyAnchor'))
.setIcon('quote-glyph') .setIcon('quote-glyph')
.onClick(() => copyToClipboard(s.anchor, this.plugin.t('copiedAnchor'))), .onClick(() => void copyToClipboard(s.anchor, this.plugin.t('copiedAnchor'))),
); );
} }
menu.addSeparator(); menu.addSeparator();
@ -319,7 +344,7 @@ export class ParallelReaderView extends ItemView {
it it
.setTitle(this.plugin.t('menuJumpSource')) .setTitle(this.plugin.t('menuJumpSource'))
.setIcon('arrow-right') .setIcon('arrow-right')
.onClick(() => this.plugin.scrollEditorToLine(s.startLine, this.sourceFile)), .onClick(() => void this.plugin.scrollEditorToLine(s.startLine, this.sourceFile)),
); );
} }
menu.addSeparator(); menu.addSeparator();
@ -388,8 +413,12 @@ export class ParallelReaderView extends ItemView {
const previousLength = this.sections.length; const previousLength = this.sections.length;
this.sections = nextSections; this.sections = nextSections;
this.activeIdx = activeIndexAfterCardDelete(index, previousLength, this.activeIdx); this.activeIdx = activeIndexAfterCardDelete(index, previousLength, this.activeIdx);
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections); const ok = await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
this.render(); this.render();
if (!ok) {
new Notice(this.plugin.t('cardPersistFailed'));
return false;
}
new Notice(this.plugin.t('cardDeleted')); new Notice(this.plugin.t('cardDeleted'));
return true; return true;
} }
@ -407,8 +436,12 @@ export class ParallelReaderView extends ItemView {
const nextSections = updateCardAt(this.sections, index, patch); const nextSections = updateCardAt(this.sections, index, patch);
if (nextSections.length !== this.sections.length) return false; if (nextSections.length !== this.sections.length) return false;
this.sections = nextSections; this.sections = nextSections;
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections); const ok = await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
this.render(); this.render();
if (!ok) {
new Notice(this.plugin.t('cardPersistFailed'));
return false;
}
new Notice(this.plugin.t('cardSaved')); new Notice(this.plugin.t('cardSaved'));
return true; return true;
} }
@ -421,7 +454,7 @@ export class ParallelReaderView extends ItemView {
const markdown = [ const markdown = [
'---', '---',
`source: [[${this.sourceFile.basename}]]`, `source: [[${this.sourceFile.path}|${this.sourceFile.basename}]]`,
`generated: ${new Date().toISOString().slice(0, 10)}`, `generated: ${new Date().toISOString().slice(0, 10)}`,
'tool: parallel-reader', 'tool: parallel-reader',
'---', '---',
@ -431,14 +464,30 @@ export class ParallelReaderView extends ItemView {
].join('\n'); ].join('\n');
const app = this.plugin.app; const app = this.plugin.app;
await ensureVaultFolder(app, folder); try {
await ensureVaultFolder(app, folder);
const existing = app.vault.getAbstractFileByPath(targetPath); const existing = app.vault.getAbstractFileByPath(targetPath);
if (existing instanceof TFile) { if (existing instanceof TFile) {
await app.vault.modify(existing, markdown); const shouldOverwrite = await confirmExportOverwrite(
} else { this.app,
await app.vault.create(targetPath, markdown); this.plugin.t('displayName'),
this.plugin.t('confirmExportOverwrite', { path: targetPath }),
this.plugin.t('confirmExportCancel'),
this.plugin.t('confirmExportOverwriteButton'),
);
if (!shouldOverwrite) {
new Notice(this.plugin.t('exportCancelled'));
return;
}
await app.vault.modify(existing, markdown);
} else {
await app.vault.create(targetPath, markdown);
}
new Notice(this.plugin.t('exported', { path: targetPath }));
} catch (e: unknown) {
const error = e instanceof Error ? e.message : String(e);
console.error('[parallel-reader] exportToVault failed', e);
new Notice(this.plugin.t('exportFailed', { error }));
} }
new Notice(this.plugin.t('exported', { path: targetPath }));
} }
} }

View file

@ -1,3 +1,89 @@
/* ---- Settings tab: collapsible sections ---- */
.parallel-reader-settings details.parallel-reader-section {
margin: 0.6em 0 0.4em;
border-top: 1px solid var(--background-modifier-border);
padding-top: 0.4em;
}
.parallel-reader-settings details.parallel-reader-section > summary.parallel-reader-section-summary {
cursor: pointer;
list-style: revert;
font-weight: 600;
font-size: 0.95em;
color: var(--text-muted);
padding: 0.4em 0;
user-select: none;
}
.parallel-reader-settings details.parallel-reader-section[open] > summary.parallel-reader-section-summary {
color: var(--text-normal);
margin-bottom: 0.4em;
}
.parallel-reader-settings .parallel-reader-preset-summary {
margin-top: 0.3em;
font-size: 0.82em;
color: var(--text-faint);
}
.parallel-reader-settings .parallel-reader-preset-summary code {
font-size: 0.95em;
background: var(--background-secondary);
padding: 0.1em 0.4em;
border-radius: 3px;
}
.parallel-reader-settings .parallel-reader-env-wrap {
width: 100%;
margin-top: 0.4em;
}
.parallel-reader-settings .parallel-reader-env-wrap details summary {
cursor: pointer;
font-size: 0.85em;
color: var(--text-muted);
padding: 0.2em 0;
}
.parallel-reader-settings .parallel-reader-env-row {
display: flex;
align-items: center;
gap: 0.6em;
margin: 0.4em 0;
}
.parallel-reader-settings .parallel-reader-env-label {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
}
.parallel-reader-settings .parallel-reader-env-input {
flex: 1;
min-width: 0;
}
.parallel-reader-settings .parallel-reader-env-desc {
font-size: 0.8em;
color: var(--text-faint);
margin-top: 0.2em;
}
.parallel-reader-settings .parallel-reader-timeout-wrap {
display: inline-flex;
align-items: center;
margin-left: 0.6em;
}
.parallel-reader-settings .parallel-reader-timeout-input {
width: 90px;
}
.parallel-reader-settings .parallel-reader-hidden {
display: none !important;
}
.parallel-reader-container { .parallel-reader-container {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
@ -387,3 +473,60 @@
color: var(--text-faint); color: var(--text-faint);
font-weight: normal; font-weight: normal;
} }
/* ---- Generation error UI: actionable notice + diagnostics modal ---- */
.parallel-reader-error-notice .parallel-reader-error-notice-message {
white-space: pre-wrap;
margin-bottom: 0.6em;
}
.parallel-reader-error-notice .parallel-reader-error-notice-actions {
display: flex;
gap: 0.4em;
flex-wrap: wrap;
}
.parallel-reader-error-notice .parallel-reader-error-notice-button {
padding: 0.25em 0.7em;
font-size: 0.85em;
cursor: pointer;
}
.parallel-reader-error-modal .parallel-reader-error-modal-grid {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 0.8em;
row-gap: 0.2em;
font-size: 0.85em;
margin: 0.6em 0;
}
.parallel-reader-error-modal .parallel-reader-error-modal-row {
display: contents;
}
.parallel-reader-error-modal .parallel-reader-error-modal-label {
color: var(--text-muted);
}
.parallel-reader-error-modal .parallel-reader-error-modal-value {
font-family: var(--font-monospace);
word-break: break-all;
}
.parallel-reader-error-modal .parallel-reader-error-modal-tail {
background: var(--background-secondary);
padding: 0.6em 0.8em;
border-radius: 4px;
font-size: 0.8em;
max-height: 240px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.parallel-reader-error-modal .parallel-reader-error-modal-empty {
color: var(--text-muted);
font-style: italic;
}

View file

@ -7,16 +7,37 @@ assert.strictEqual(t.findLineForAnchor('line0\nline1\nhello', 'hello'), 2, 'exac
assert.strictEqual(t.findLineForAnchor('line0\nline1\nhello world', ' hello world '), 2, 'trimmed match'); assert.strictEqual(t.findLineForAnchor('line0\nline1\nhello world', ' hello world '), 2, 'trimmed match');
assert.strictEqual( assert.strictEqual(
t.findLineForAnchor('intro\nThe quick brown fox jumps over the lazy dog\nend', 'The quick brown fox jumps'), t.findLineForAnchor('intro\nThe quick brown fox jumps over the lazy dog\nend', 'The quick brown fox jumps'),
1, 'prefix match at 60-char threshold' 1,
'prefix match at 60-char threshold',
); );
assert.strictEqual( assert.strictEqual(
t.findLineForAnchor('a\nb\nc\nd\ne\nAlpha beta\nGamma\tDelta\nlast', 'Alpha beta Gamma Delta'), t.findLineForAnchor('a\nb\nc\nd\ne\nAlpha beta\nGamma\tDelta\nlast', 'Alpha beta Gamma Delta'),
5, 'normalized whitespace match returns correct line' 5,
'normalized whitespace match returns correct line',
); );
assert.strictEqual(t.findLineForAnchor('hello\nworld', 'zzz_not_found'), -1, 'unmatched anchor returns -1'); assert.strictEqual(t.findLineForAnchor('hello\nworld', 'zzz_not_found'), -1, 'unmatched anchor returns -1');
assert.strictEqual( assert.strictEqual(
t.findLineForAnchor('first line\nsecond with 日本語 text\nthird', '日本語'), t.findLineForAnchor('first line\nsecond with 日本語 text\nthird', '日本語'),
1, 'unicode anchor match' 1,
'unicode anchor match',
); );
// ── perf: precomputed line-offset index must yield identical results ──
const doc = 'l0\nl1\nl2 needle here\nl3\n\nl5 tail';
const offsets = t.buildLineOffsets(doc);
assert.deepStrictEqual(offsets, [0, 3, 6, 21, 24, 25], 'buildLineOffsets marks each line start');
assert.strictEqual(
t.findLineForAnchor(doc, 'needle here', offsets),
t.findLineForAnchor(doc, 'needle here'),
'threaded offsets match self-computed result (exact path)',
);
assert.strictEqual(t.findLineForAnchor(doc, 'tail', offsets), 5, 'threaded offsets resolve trailing line');
// Non-ASCII whitespace (NBSP  ) must still normalize like /\s/ did.
assert.strictEqual(
t.findLineForAnchor('x\ny\nAlpha  beta gamma\nz', 'Alpha beta gamma'),
2,
'NBSP collapses to single space in normalized fallback match',
);
assert.strictEqual(t.buildLineOffsets('')[0], 0, 'empty content still has line 0');
console.log('anchor tests passed'); console.log('anchor tests passed');

View file

@ -0,0 +1,42 @@
/**
* Architecture guard tests verify source code structure invariants
* that cannot be expressed via TypeScript types or lint rules.
*/
const fs = require('fs');
const path = require('path');
const { assert } = require('./test-setup');
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8');
const viewSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'view.ts'), 'utf8');
// View lifecycle guards
assert.ok(!/\basync\s+onOpen\s*\(/.test(viewSource), 'ParallelReaderView.onOpen should not be async without await');
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(viewSource), 'empty onClose should not be async');
assert.ok(/focusSummaryPane\s*\(\)/.test(viewSource), 'summary pane should expose a focus helper');
assert.ok(
/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(viewSource),
'summary pane focus should not scroll the page',
);
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(viewSource), 'card navigation should focus the summary pane');
// Cache debounce guards
assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path');
assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable');
assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches');
assert.ok(/cacheTouch[\s\S]*scheduleCacheSave/.test(mainSource), 'cacheTouch should schedule a cache save');
assert.ok(
!/cacheTouch[\s\S]{0,220}await this\.saveCache/.test(mainSource),
'cacheTouch should not synchronously write cache.json',
);
assert.ok(/handleFileRename[\s\S]*cacheManager\.move/.test(mainSource), 'file rename should delegate cache moves');
assert.ok(
!/handleFileRename[\s\S]*cacheManager\.cache\[/.test(mainSource),
'file rename should not mutate cache directly',
);
// Module extraction guards
assert.ok(!/function addIconButton/.test(mainSource), 'UI icon helper should live outside main.ts');
assert.ok(!/function addTextButton/.test(mainSource), 'UI text-button helper should live outside main.ts');
assert.ok(!/function copyToClipboard/.test(mainSource), 'clipboard helper should live outside main.ts');
console.log('architecture tests passed');

221
tests/backend-test.test.js Normal file
View file

@ -0,0 +1,221 @@
const { assert, baseSettings, EventEmitter, openAiCardsResponse, t } = require('./test-setup');
function createFakeChild(stdoutText, stderrText = '', code = 0) {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = {
written: '',
ended: false,
write(value) {
this.written += value;
},
end() {
this.ended = true;
},
};
child.pid = 1234;
child.kill = () => true;
process.nextTick(() => {
if (stdoutText) child.stdout.emit('data', Buffer.from(stdoutText));
if (stderrText) child.stderr.emit('data', Buffer.from(stderrText));
child.emit('close', code, null);
});
return child;
}
async function testClaudeBackendRunsVersionAndSmoke() {
const calls = [];
const streamJson = [
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}',
}),
'',
].join('\n');
const spawnImpl = (cmd, args) => {
calls.push({ cmd, args });
if (args.includes('--version')) return createFakeChild('2.1.133 (Claude Code)\n');
return createFakeChild(streamJson);
};
const result = await t.testBackend(
{
...baseSettings,
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 300000,
model: 'claude-sonnet-4-6',
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
},
{ spawnImpl },
);
assert.match(result, /claude @ \/usr\/bin\/claude/, 'result shows resolved Claude command');
assert.match(result, /2\.1\.133/, 'result includes CLI version output');
assert.match(result, /smoke: 1 card/, 'result includes parsed smoke-card count');
assert.deepStrictEqual(calls[0].args, ['--version'], 'first Claude call checks version');
const smokeArgs = calls[1].args;
assert.strictEqual(smokeArgs[0], '-p', 'Claude smoke uses print mode');
assert.strictEqual(
smokeArgs[smokeArgs.indexOf('--output-format') + 1],
'stream-json',
'Claude smoke uses stream-json for incremental output',
);
assert.ok(
smokeArgs.includes('--verbose'),
'Claude smoke must include --verbose (required alongside stream-json on CLI 2.1.131+)',
);
assert.strictEqual(smokeArgs[smokeArgs.indexOf('--tools') + 1], '', 'Claude smoke disables tools');
assert.ok(smokeArgs.includes('--strict-mcp-config'), 'Claude smoke ignores user/project MCP servers');
assert.strictEqual(
smokeArgs[smokeArgs.indexOf('--mcp-config') + 1],
'{"mcpServers":{}}',
'Claude smoke passes an empty MCP config',
);
}
async function testCodexBackendRunsVersionAndSmoke() {
const calls = [];
const resultJson =
'{"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}';
const spawnImpl = (cmd, args) => {
calls.push({ cmd, args });
if (args.includes('--version')) return createFakeChild('codex-cli 1.2.3\n');
return createFakeChild(resultJson);
};
const result = await t.testBackend(
{
...baseSettings,
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 300000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
},
{ spawnImpl },
);
assert.match(result, /codex @ \/usr\/bin\/codex/, 'result shows resolved Codex command');
assert.match(result, /codex-cli 1\.2\.3/, 'result includes CLI version output');
assert.match(result, /smoke: 1 card/, 'result includes parsed smoke-card count');
assert.deepStrictEqual(calls[0].args, ['--version'], 'first Codex call checks version');
assert.strictEqual(calls[1].args[0], 'exec', 'Codex smoke runs through codex exec');
assert.strictEqual(calls[1].args[calls[1].args.indexOf('--sandbox') + 1], 'read-only', 'Codex smoke is read-only');
}
async function testApiBackendUsesInjectedRequestUrl() {
let called = false;
const result = await t.testBackend(
{
...baseSettings,
apiHeaders: '',
maxDocChars: 100000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
},
{
requestUrlImpl: async () => {
called = true;
return openAiCardsResponse([]);
},
},
);
assert.strictEqual(called, true, 'API backend test should use injected requestUrl');
assert.strictEqual(result, 'OpenAI / OpenAI Chat Completions', 'API backend test reports provider and format');
}
// Regression: smoke flow must propagate the canary stderr that real Claude CLI
// emits when --output-format=stream-json is passed without --verbose.
async function testClaudeBackendSurfacesVerboseError() {
const spawnImpl = (_cmd, args) => {
if (args.includes('--version')) return createFakeChild('2.1.133 (Claude Code)\n');
// smoke call: simulate the exact stderr the real CLI emits when --verbose is missing
return createFakeChild('', 'Error: When using --print, --output-format=stream-json requires --verbose', 1);
};
await assert.rejects(
() =>
t.testBackend(
{
...baseSettings,
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 300000,
model: 'claude-sonnet-4-6',
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
},
{ spawnImpl },
),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'verbose-regression smoke surfaces CliProcessError');
assert.match(err.details.stderrTail, /requires --verbose/, 'stderr tail preserves the verbose-flag hint');
assert.strictEqual(err.details.exitCode, 1, 'preserves CLI exit code so UI can classify');
return true;
},
'testBackend must propagate Claude smoke failures (including verbose-flag regression) as structured errors',
);
}
// Regression: if `<cli> --version` itself fails, testBackend must throw before reaching smoke,
// not silently report success with garbage version output.
async function testCodexBackendPropagatesVersionFailure() {
const calls = [];
const spawnImpl = (_cmd, args) => {
calls.push(args);
if (args.includes('--version')) return createFakeChild('', 'codex: command not found', 127);
return createFakeChild('{"cards":[]}');
};
await assert.rejects(
() =>
t.testBackend(
{
...baseSettings,
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 300000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
},
{ spawnImpl },
),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'version failure surfaces structured CliProcessError');
assert.strictEqual(err.details.exitCode, 127, 'preserves shell command-not-found exit code');
return true;
},
'testBackend must not swallow version-probe failures',
);
assert.strictEqual(calls.length, 1, 'smoke call must NOT run after version probe fails');
assert.deepStrictEqual(calls[0], ['--version'], 'only the version probe should have been spawned');
}
(async () => {
await testClaudeBackendRunsVersionAndSmoke();
await testClaudeBackendSurfacesVerboseError();
await testCodexBackendRunsVersionAndSmoke();
await testCodexBackendPropagatesVersionFailure();
await testApiBackendUsesInjectedRequestUrl();
console.log('backend-test tests passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -5,7 +5,11 @@ const { assert, t } = require('./test-setup');
assert.strictEqual(t.touchCacheEntry(null), null, 'touchCacheEntry on null returns null'); assert.strictEqual(t.touchCacheEntry(null), null, 'touchCacheEntry on null returns null');
const entry = { generatedAt: '2024-01-01T00:00:00.000Z' }; const entry = { generatedAt: '2024-01-01T00:00:00.000Z' };
const touched = t.touchCacheEntry(entry, '2024-06-01T00:00:00.000Z'); const touched = t.touchCacheEntry(entry, '2024-06-01T00:00:00.000Z');
assert.strictEqual(touched.lastAccessedAt, '2024-06-01T00:00:00.000Z', 'touchCacheEntry sets lastAccessedAt on returned entry'); assert.strictEqual(
touched.lastAccessedAt,
'2024-06-01T00:00:00.000Z',
'touchCacheEntry sets lastAccessedAt on returned entry',
);
assert.strictEqual(entry.lastAccessedAt, undefined, 'touchCacheEntry does not mutate original entry'); assert.strictEqual(entry.lastAccessedAt, undefined, 'touchCacheEntry does not mutate original entry');
const serialized = t.serializeCacheFile({ 'a.md': { cards: [] } }); const serialized = t.serializeCacheFile({ 'a.md': { cards: [] } });
@ -45,7 +49,11 @@ async function testCacheManagerMove() {
assert.strictEqual(await manager.move(' ', 'new.md'), false, 'blank source path is rejected'); assert.strictEqual(await manager.move(' ', 'new.md'), false, 'blank source path is rejected');
assert.strictEqual(await manager.move('old.md', ' '), false, 'blank target path is rejected'); assert.strictEqual(await manager.move('old.md', ' '), false, 'blank target path is rejected');
assert.strictEqual(await manager.move('old.md', 'old.md'), true, 'same-path move is a no-op success'); assert.strictEqual(await manager.move('old.md', 'old.md'), true, 'same-path move is a no-op success');
assert.strictEqual(await manager.move('old.md', 'other.md'), false, 'move does not overwrite an existing target path'); assert.strictEqual(
await manager.move('old.md', 'other.md'),
false,
'move does not overwrite an existing target path',
);
assert.strictEqual(writes.length, 0, 'no-op and rejected cache moves are not persisted'); assert.strictEqual(writes.length, 0, 'no-op and rejected cache moves are not persisted');
assert.strictEqual(await manager.move('old.md', 'new.md'), true, 'existing cache move returns true'); assert.strictEqual(await manager.move('old.md', 'new.md'), true, 'existing cache move returns true');
assert.strictEqual(manager.cache['old.md'], undefined, 'old cache path is removed'); assert.strictEqual(manager.cache['old.md'], undefined, 'old cache path is removed');

59
tests/catalog.json Normal file
View file

@ -0,0 +1,59 @@
{
"version": 1,
"defaultCategories": ["unit", "component", "contract"],
"categories": {
"unit": {
"description": "Pure logic tests with no Obsidian runtime, filesystem, process, or provider boundary.",
"files": [
"anchor.test.js",
"cards-nav.test.js",
"direct-batch.test.js",
"direct-generation.test.js",
"direct-i18n.test.js",
"direct-markdown.test.js",
"direct-prompt.test.js",
"direct-settings-extra.test.js",
"direct-settings.test.js",
"direct-streaming.test.js",
"i18n.test.js",
"markdown.test.js",
"schema.test.js",
"scroll.test.js",
"settings.test.js",
"streaming.test.js",
"vault-batch.test.js"
]
},
"component": {
"description": "Tests that exercise plugin components with controlled adapters, job managers, or Obsidian shims.",
"files": [
"cache.test.js",
"direct-cache.test.js",
"generation-job-manager.test.js",
"plugin-batch.test.js",
"view-render.test.js"
]
},
"contract": {
"description": "Tests for provider protocols, CLI command contracts, exported test surfaces, and architecture invariants.",
"files": [
"architecture.test.js",
"backend-test.test.js",
"cli.test.js",
"direct-providers.test.js",
"providers.test.js",
"test-exports.test.js"
]
}
},
"externalCategories": {
"e2e": {
"description": "Headless product-shell smoke using the built plugin package and a disposable Vault filesystem.",
"command": "npm run test:e2e"
},
"live": {
"description": "Opt-in check against a real local Vault plugin install. Does not launch Obsidian or call a provider.",
"command": "TEST_LIVE=1 npm run test:live"
}
}
}

View file

@ -34,20 +34,48 @@ function createFakeChild() {
child.stdin = { child.stdin = {
written: '', written: '',
ended: false, ended: false,
write(value) { this.written += value; }, write(value) {
end() { this.ended = true; }, this.written += value;
},
end() {
this.ended = true;
},
}; };
child.killedWith = null; child.killedWith = null;
child.kill = (signal) => { child.killedWith = signal; return true; }; child.kill = (signal) => {
child.killedWith = signal;
return true;
};
return child; return child;
} }
async function testRunCliEdgeCases() { async function testRunCliEdgeCases() {
const timeoutChild = createFakeChild(); const timeoutChild = createFakeChild();
timeoutChild.pid = 4242;
await assert.rejects( await assert.rejects(
() => t.runCli('fake', [], '', 5, undefined, () => timeoutChild), () => {
/CLI timed out \(5ms\)/, // emit some bytes before timeout so we can verify the diagnostic suffix carries them
'runCli rejects on timeout', setImmediate(() => {
timeoutChild.stderr.emit('data', Buffer.from('boom: connection reset'));
});
return t.runCli('fake', [], '', 20, undefined, () => timeoutChild);
},
(err) => {
assert.match(err.message, /CLI timed out \(20ms\)/, 'wall-clock timeout keeps original prefix');
assert.match(err.message, /pid=4242/, 'timeout error carries pid');
assert.match(err.message, /stderr=\d+B/, 'timeout error carries stderr byte count');
assert.match(err.message, /stderr tail/, 'timeout error includes stderr tail');
assert.match(err.message, /boom: connection reset/, 'timeout error preserves last stderr line');
// Structured CliProcessError details (P0 #2: error UI dispatch needs typed access).
assert.ok(err instanceof t.CliProcessError, 'wall-timeout throws CliProcessError');
assert.strictEqual(err.details.reason, 'wall-timeout', 'wall-timeout reason set');
assert.strictEqual(err.details.pid, 4242, 'details.pid populated');
assert.ok(err.details.stderrBytes >= 'boom: connection reset'.length, 'details.stderrBytes utf-8 byte count');
assert.match(err.details.stderrTail, /boom: connection reset/, 'details.stderrTail preserved');
assert.strictEqual(err.details.timeoutMs, 20, 'details.timeoutMs reflects wall timeout');
return true;
},
'runCli rejects on timeout with rich diagnostics',
); );
assert.strictEqual(timeoutChild.killedWith, 'SIGKILL', 'runCli kills timed out processes'); assert.strictEqual(timeoutChild.killedWith, 'SIGKILL', 'runCli kills timed out processes');
@ -58,7 +86,12 @@ async function testRunCliEdgeCases() {
[], [],
'', '',
1000, 1000,
{ key: 'cancel.md', onCancel: (handler) => { cancelHandler = handler; } }, {
key: 'cancel.md',
onCancel: (handler) => {
cancelHandler = handler;
},
},
() => cancelChild, () => cancelChild,
); );
cancelHandler(); cancelHandler();
@ -71,33 +104,126 @@ async function testRunCliEdgeCases() {
await assert.rejects( await assert.rejects(
() => t.runCli('fake', [], '', 100, undefined, () => ({ stdout: null, stderr: null, stdin: null })), () => t.runCli('fake', [], '', 100, undefined, () => ({ stdout: null, stderr: null, stdin: null })),
/CLI process streams are unavailable/, (err) => {
assert.match(err.message, /CLI process streams are unavailable/, 'streams-unavailable message preserved');
assert.ok(err instanceof t.CliProcessError, 'streams-unavailable throws CliProcessError');
assert.strictEqual(err.details.reason, 'streams-unavailable', 'streams-unavailable reason set');
return true;
},
'runCli reports unavailable stdio streams', 'runCli reports unavailable stdio streams',
); );
// Non-zero exit still throws a typed CliProcessError so dispatcher can inspect stderr.
const exitChild = createFakeChild();
exitChild.pid = 7373;
const exitPromise = t.runCli('fake', [], '', 1000, undefined, () => exitChild);
setImmediate(() => {
exitChild.stderr.emit('data', Buffer.from('error: invalid api key'));
exitChild.emit('close', 2, null);
});
await assert.rejects(
() => exitPromise,
(err) => {
assert.ok(err instanceof t.CliProcessError, 'exit-nonzero throws CliProcessError');
assert.strictEqual(err.details.reason, 'exit-nonzero', 'exit-nonzero reason set');
assert.strictEqual(err.details.exitCode, 2, 'details.exitCode populated');
assert.match(err.details.stderrTail, /invalid api key/, 'details.stderrTail captures auth hint');
return true;
},
'runCli exposes non-zero exit code in structured details',
);
// Diagnostic suffix must redact secrets that show up in stderr (Bearer tokens, sk- keys, etc.)
const secretChild = createFakeChild();
secretChild.pid = 6363;
await assert.rejects(
() => {
setImmediate(() => {
secretChild.stderr.emit(
'data',
Buffer.from('Authorization: Bearer sk-ant-supersecrettoken-xyz123 failed handshake'),
);
});
return t.runCli('fake', [], '', 20, undefined, () => secretChild);
},
(err) => {
assert.doesNotMatch(err.message, /sk-ant-supersecrettoken-xyz123/, 'token must not appear verbatim');
assert.match(err.message, /\[REDACTED\]/, 'redaction marker present');
assert.match(err.message, /failed handshake/, 'non-secret context preserved');
return true;
},
'runCli timeout suffix redacts secrets',
);
} }
// ── summarizeViaClaudeCode args validation ── // ── summarizeViaClaudeCode args validation ──
// Known flags supported by the Claude Code CLI (from `claude --help`). // Known flags supported by the Claude Code CLI (from `claude --help`).
const VALID_CLAUDE_FLAGS = new Set([ const VALID_CLAUDE_FLAGS = new Set([
'--add-dir', '--agent', '--agents', '--allow-dangerously-skip-permissions', '--add-dir',
'--allowedTools', '--allowed-tools', '--append-system-prompt', '--bare', '--agent',
'--betas', '--brief', '--chrome', '--dangerously-skip-permissions', '--agents',
'--debug-file', '--disable-slash-commands', '--disallowedTools', '--allow-dangerously-skip-permissions',
'--disallowed-tools', '--effort', '--exclude-dynamic-system-prompt-sections', '--allowedTools',
'--fallback-model', '--file', '--fork-session', '--from-pr', '--ide', '--allowed-tools',
'--include-hook-events', '--include-partial-messages', '--input-format', '--append-system-prompt',
'--json-schema', '--max-budget-usd', '--mcp-config', '--mcp-debug', '--bare',
'--model', '--no-chrome', '--no-session-persistence', '--output-format', '--betas',
'--permission-mode', '--plugin-dir', '--remote-control-session-name-prefix', '--brief',
'--replay-user-messages', '--session-id', '--setting-sources', '--settings', '--chrome',
'--strict-mcp-config', '--system-prompt', '--tmux', '--tools', '--verbose', '--dangerously-skip-permissions',
'-p', '-c', '-d', '-h', '-n', '-r', '-v', '-w', '--debug-file',
'--disable-slash-commands',
'--disallowedTools',
'--disallowed-tools',
'--effort',
'--exclude-dynamic-system-prompt-sections',
'--fallback-model',
'--file',
'--fork-session',
'--from-pr',
'--ide',
'--include-hook-events',
'--include-partial-messages',
'--input-format',
'--json-schema',
'--max-budget-usd',
'--mcp-config',
'--mcp-debug',
'--model',
'--no-chrome',
'--no-session-persistence',
'--output-format',
'--permission-mode',
'--plugin-dir',
'--remote-control-session-name-prefix',
'--replay-user-messages',
'--session-id',
'--setting-sources',
'--settings',
'--strict-mcp-config',
'--system-prompt',
'--tmux',
'--tools',
'--verbose',
'-p',
'-c',
'-d',
'-h',
'-n',
'-r',
'-v',
'-w',
]); ]);
async function testClaudeCodeArgs() { async function testClaudeCodeArgs() {
let capturedArgs = null; let capturedArgs = null;
const resultJson = JSON.stringify([{ type: 'result', result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}' }]); const resultJson = JSON.stringify([
{
type: 'result',
result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
},
]);
const fakeSpawn = (_cmd, args) => { const fakeSpawn = (_cmd, args) => {
capturedArgs = args; capturedArgs = args;
const child = createFakeChild(); const child = createFakeChild();
@ -124,34 +250,123 @@ async function testClaudeCodeArgs() {
assert.ok(capturedArgs, 'summarizeViaClaudeCode should have called spawn'); assert.ok(capturedArgs, 'summarizeViaClaudeCode should have called spawn');
// Verify every --flag in the args is a valid Claude CLI flag // Verify every --flag in the args is a valid Claude CLI flag
const flags = capturedArgs.filter(a => a.startsWith('-')); const flags = capturedArgs.filter((a) => a.startsWith('-'));
for (const flag of flags) { for (const flag of flags) {
assert.ok( assert.ok(VALID_CLAUDE_FLAGS.has(flag), `summarizeViaClaudeCode passes unsupported flag "${flag}" to claude CLI`);
VALID_CLAUDE_FLAGS.has(flag),
`summarizeViaClaudeCode passes unsupported flag "${flag}" to claude CLI`,
);
} }
// Verify key expected flags are present // Verify key expected flags are present
assert.ok(capturedArgs.includes('-p'), 'should include -p (print mode)'); assert.ok(capturedArgs.includes('-p'), 'should include -p (print mode)');
assert.ok(capturedArgs.includes('--output-format'), 'should include --output-format'); assert.ok(capturedArgs.includes('--output-format'), 'should include --output-format');
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--output-format') + 1],
'stream-json',
'claude output should stream JSON events',
);
assert.ok(capturedArgs.includes('--verbose'), 'stream-json requires --verbose in Claude CLI 2.1.131+');
assert.ok(capturedArgs.includes('--append-system-prompt'), 'should include --append-system-prompt'); assert.ok(capturedArgs.includes('--append-system-prompt'), 'should include --append-system-prompt');
assert.ok(capturedArgs.includes('--tools'), 'should explicitly restrict Claude tools');
assert.strictEqual(capturedArgs[capturedArgs.indexOf('--tools') + 1], '', 'Claude tools should be disabled');
assert.ok(capturedArgs.includes('--disallowed-tools'), 'should include --disallowed-tools'); assert.ok(capturedArgs.includes('--disallowed-tools'), 'should include --disallowed-tools');
assert.ok(capturedArgs.includes('--no-session-persistence'), 'should avoid session persistence for plugin calls');
assert.ok(capturedArgs.includes('--disable-slash-commands'), 'should disable slash commands for plugin calls');
assert.ok(capturedArgs.includes('--no-chrome'), 'should suppress Claude TUI chrome');
assert.ok(capturedArgs.includes('--strict-mcp-config'), 'should ignore user/project MCP servers');
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--mcp-config') + 1],
'{"mcpServers":{}}',
'--mcp-config must pair with an empty MCP server map',
);
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--disallowed-tools') + 1],
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,LSP,NotebookEdit',
'--disallowed-tools must list every tool that could exfiltrate or mutate',
);
// Ordering: -p must come before --output-format so Claude treats the run as print-mode.
assert.ok(
capturedArgs.indexOf('-p') < capturedArgs.indexOf('--output-format'),
'-p must appear before --output-format',
);
// --verbose must accompany --output-format stream-json (Claude CLI 2.1.131+ requirement).
// The CLI does not constrain position; we only check that --verbose is not consumed as the value of
// another flag — i.e. it must not appear at index `--output-format` + 1 (where 'stream-json' belongs).
const outputFmtIdx = capturedArgs.indexOf('--output-format');
const verboseIdx = capturedArgs.indexOf('--verbose');
assert.notStrictEqual(verboseIdx, outputFmtIdx + 1, '--verbose must not be parsed as the --output-format value');
// Verify --max-tokens is NOT present (it's not a valid claude CLI flag) // Verify --max-tokens is NOT present (it's not a valid claude CLI flag)
assert.ok(!capturedArgs.includes('--max-tokens'), 'should NOT include --max-tokens'); assert.ok(!capturedArgs.includes('--max-tokens'), 'should NOT include --max-tokens');
} }
async function testClaudeCodeStreamJsonOutput() {
const streamJson = [
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
'',
].join('\n');
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(streamJson));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
const cards = await t.summarizeViaClaudeCode('system prompt', 'user content', settings, undefined, fakeSpawn);
assert.strictEqual(cards.length, 1, 'claude stream-json result event is parsed');
assert.strictEqual(cards[0].title, 'T', 'parsed card title preserved');
}
// ── summarizeViaCodex args validation ── // ── summarizeViaCodex args validation ──
// Known flags/subcommands supported by `codex exec` (from `codex exec --help`). // Known flags/subcommands supported by `codex exec` (from `codex exec --help`).
const VALID_CODEX_EXEC_FLAGS = new Set([ const VALID_CODEX_EXEC_FLAGS = new Set([
'-c', '--config', '--enable', '--disable', '-i', '--image', '-c',
'-m', '--model', '--oss', '--local-provider', '-p', '--profile', '--config',
'-s', '--sandbox', '--full-auto', '--dangerously-bypass-approvals-and-sandbox', '--enable',
'-C', '--cd', '--add-dir', '--skip-git-repo-check', '--ephemeral', '--disable',
'--ignore-user-config', '--ignore-rules', '--output-schema', '--color', '-i',
'--json', '-o', '--output-last-message', '-h', '--help', '-V', '--version', '--image',
'-m',
'--model',
'--oss',
'--local-provider',
'-p',
'--profile',
'-s',
'--sandbox',
'--full-auto',
'--dangerously-bypass-approvals-and-sandbox',
'-C',
'--cd',
'--add-dir',
'--skip-git-repo-check',
'--ephemeral',
'--ignore-user-config',
'--ignore-rules',
'--output-schema',
'--color',
'--json',
'-o',
'--output-last-message',
'-h',
'--help',
'-V',
'--version',
]); ]);
async function testCodexArgs() { async function testCodexArgs() {
@ -185,12 +400,9 @@ async function testCodexArgs() {
assert.strictEqual(capturedArgs[0], 'exec', 'first arg should be exec subcommand'); assert.strictEqual(capturedArgs[0], 'exec', 'first arg should be exec subcommand');
// Verify every --flag (after 'exec') is valid for `codex exec` // Verify every --flag (after 'exec') is valid for `codex exec`
const flags = capturedArgs.slice(1).filter(a => a.startsWith('-') && a !== '-'); const flags = capturedArgs.slice(1).filter((a) => a.startsWith('-') && a !== '-');
for (const flag of flags) { for (const flag of flags) {
assert.ok( assert.ok(VALID_CODEX_EXEC_FLAGS.has(flag), `summarizeViaCodex passes unsupported flag "${flag}" to codex exec`);
VALID_CODEX_EXEC_FLAGS.has(flag),
`summarizeViaCodex passes unsupported flag "${flag}" to codex exec`,
);
} }
// Verify key expected flags are present // Verify key expected flags are present
@ -200,6 +412,271 @@ async function testCodexArgs() {
// Verify stdin content combines system and user prompts // Verify stdin content combines system and user prompts
assert.ok(capturedArgs.includes('exec'), 'should include exec subcommand'); assert.ok(capturedArgs.includes('exec'), 'should include exec subcommand');
// Verify sandbox is read-only (security: prevent prompt injection from running tools)
const sandboxIdx = capturedArgs.indexOf('--sandbox');
assert.ok(sandboxIdx >= 0, 'should pass --sandbox to codex exec');
assert.strictEqual(capturedArgs[sandboxIdx + 1], 'read-only', 'sandbox value must be read-only');
// Codex never receives --model (settings.model defaults to a Claude-name and would break codex)
assert.ok(!capturedArgs.includes('--model'), 'codex args must not include --model (defers to codex config)');
}
async function testCodexArgsIgnoresClaudeDefaultModel() {
let capturedArgs = null;
const resultJson = '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}';
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
// Even if settings.model is set (e.g. user has DEFAULT_SETTINGS.model = 'claude-sonnet-4-6'),
// codex must NOT receive --model.
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
model: 'claude-sonnet-4-6',
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaCodex('system prompt', 'user content', settings, undefined, fakeSpawn);
assert.ok(!capturedArgs.includes('--model'), 'codex must ignore settings.model regardless of value');
}
async function testClaudeCodeArgsWithModel() {
let capturedArgs = null;
const resultJson = JSON.stringify([
{
type: 'result',
result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
},
]);
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
const baseSettings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
apiMaxTokens: 8192,
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaClaudeCode('system prompt', 'user content', baseSettings, undefined, fakeSpawn);
assert.ok(!capturedArgs.includes('--model'), 'claude args omit --model when settings.model unset');
await t.summarizeViaClaudeCode('system', 'user', { ...baseSettings, model: 'claude-opus-4-7' }, undefined, fakeSpawn);
const modelIdx = capturedArgs.indexOf('--model');
assert.ok(modelIdx >= 0, 'claude should pass --model when settings.model set');
assert.strictEqual(capturedArgs[modelIdx + 1], 'claude-opus-4-7', 'claude --model value matches settings.model');
}
// ── Claude stream-json parse resilience ──
async function runClaudeCodeWithStdout(stdoutText) {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
if (stdoutText) child.stdout.emit('data', Buffer.from(stdoutText));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
return t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn);
}
async function testClaudeCodeStreamJsonResilience() {
// B1: NDJSON with non-JSON banner line — must skip banner and still parse the result event.
const withBanner = [
'Claude Code 2.1.131',
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"B1","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
].join('\n');
const b1 = await runClaudeCodeWithStdout(withBanner);
assert.strictEqual(b1.length, 1, 'B1: banner line is skipped and result parsed');
assert.strictEqual(b1[0].title, 'B1', 'B1: card title preserved');
// B2: Missing result event — must throw a recognizable "no result" error (not a crash inside parser).
// Match both zh ("没有返回结果") and en ("returned no result") so the assertion survives translate() fallback differences.
const noResultRegex = /没有返回结果|returned no result/i;
const onlyInit = JSON.stringify({ type: 'system', subtype: 'init', tools: [] }) + '\n';
await assert.rejects(
() => runClaudeCodeWithStdout(onlyInit),
(err) => noResultRegex.test(err.message),
'B2: missing result event throws a descriptive "no result" error',
);
// B3: Empty stdout — same behavior: must throw, not crash on undefined / silent success.
await assert.rejects(
() => runClaudeCodeWithStdout(''),
(err) => noResultRegex.test(err.message),
'B3: empty stdout throws a descriptive "no result" error',
);
// B4: Multiple result events — last one wins (claudeResultText scans in reverse).
const twoResults = [
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"FIRST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"LAST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
].join('\n');
const b4 = await runClaudeCodeWithStdout(twoResults);
assert.strictEqual(b4.length, 1, 'B4: latest result event is used');
assert.strictEqual(b4[0].title, 'LAST', 'B4: last result wins over earlier ones');
// B5: Single-event object with `content` (not `result`) — covers the legacy fallback path
// where claudeResultText reads `events[0].content` when no `type: result` event exists.
const contentFallback = JSON.stringify({
content: '{"cards":[{"title":"B5","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
});
const b5 = await runClaudeCodeWithStdout(contentFallback);
assert.strictEqual(b5.length, 1, 'B5: events[0].content fallback parses card');
assert.strictEqual(b5[0].title, 'B5', 'B5: content-fallback card title preserved');
}
// ── Claude verbose-missing error-path plumbing ──
// This does NOT guard the --verbose flag itself (that is locked by
// `testClaudeCodeArgs`'s `capturedArgs.includes('--verbose')` assertion, which fails
// in fake-spawn before any stderr is even simulated).
// Instead, this test simulates the *real* stderr that Claude CLI emits when
// --verbose is missing, and asserts that the error-propagation pipeline preserves
// exit code + stderr tail so the UI can render a useful diagnostic. If we ever
// rework error handling and accidentally swallow CLI stderr, this catches it.
async function testClaudeCodeVerboseRegressionCanary() {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stderr.emit(
'data',
Buffer.from('Error: When using --print, --output-format=stream-json requires --verbose'),
);
child.emit('close', 1, null);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await assert.rejects(
() => t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'canary: throws CliProcessError on exit-nonzero');
assert.strictEqual(err.details.reason, 'exit-nonzero', 'canary: exit-nonzero reason');
assert.strictEqual(err.details.exitCode, 1, 'canary: surfaces CLI exit code');
assert.match(err.details.stderrTail, /requires --verbose/, 'canary: preserves verbose-flag hint in stderr');
return true;
},
'Claude CLI verbose-missing stderr surfaces as a structured CliProcessError',
);
}
// ── Codex error path ──
async function testCodexErrorPath() {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stderr.emit('data', Buffer.from('error: not authenticated, please run codex login'));
child.emit('close', 2, null);
});
return child;
};
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await assert.rejects(
() => t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'codex: throws CliProcessError on exit-nonzero');
assert.strictEqual(err.details.exitCode, 2, 'codex: exit code preserved');
assert.match(err.details.stderrTail, /not authenticated/, 'codex: stderr tail preserves auth hint');
return true;
},
'codex non-zero exit propagates structured error',
);
}
// ── Codex args minimal contract ──
// Lock in the exact minimal arg shape: any drift means we are leaking new flags
// into a CLI that is intentionally kept minimal (no --model, no --cd, no -c).
async function testCodexArgsMinimalContract() {
let capturedArgs = null;
const resultJson = '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}';
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn);
// Exact list lock: changes here are intentional and require updating the test.
assert.deepStrictEqual(
capturedArgs,
['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '-'],
'codex exec args must remain minimal (exec + skip-git + read-only sandbox + stdin marker)',
);
} }
// ── classifyGenerationError ── // ── classifyGenerationError ──
@ -208,9 +685,87 @@ assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'a
assert.strictEqual(t.classifyGenerationError(new Error('API key is not set')), 'auth'); assert.strictEqual(t.classifyGenerationError(new Error('API key is not set')), 'auth');
assert.strictEqual(t.classifyGenerationError(new Error('CLI timed out (120000ms)')), 'timeout'); assert.strictEqual(t.classifyGenerationError(new Error('CLI timed out (120000ms)')), 'timeout');
assert.strictEqual(t.classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout'); assert.strictEqual(t.classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
// Structured CliProcessError short-circuits message-regex matching for deterministic reasons.
const wallTimeout = new t.CliProcessError('whatever message text', {
reason: 'wall-timeout',
cmd: 'claude',
pid: 1,
elapsedMs: 0,
idleMs: 0,
stdoutBytes: 0,
stderrBytes: 0,
stdoutTail: '',
stderrTail: '',
exitCode: null,
signal: null,
timeoutMs: 0,
});
assert.strictEqual(t.classifyGenerationError(wallTimeout), 'timeout', 'wall-timeout details → timeout kind');
const spawnFailure = new t.CliProcessError('Failed to start claude', {
reason: 'spawn-failure',
cmd: 'claude',
pid: null,
elapsedMs: 0,
idleMs: null,
stdoutBytes: 0,
stderrBytes: 0,
stdoutTail: '',
stderrTail: '',
exitCode: null,
signal: null,
timeoutMs: 0,
});
assert.strictEqual(t.classifyGenerationError(spawnFailure), 'config', 'spawn-failure details → config kind');
// exit-nonzero falls through so stderr-derived auth/rate-limit messages can still classify.
const exitWithAuthStderr = new t.CliProcessError('CLI exited with code 1\nstderr:\nerror: API key invalid', {
reason: 'exit-nonzero',
cmd: 'claude',
pid: 1,
elapsedMs: 0,
idleMs: 0,
stdoutBytes: 0,
stderrBytes: 0,
stdoutTail: '',
stderrTail: '',
exitCode: 1,
signal: null,
timeoutMs: 0,
});
assert.strictEqual(
t.classifyGenerationError(exitWithAuthStderr),
'auth',
'exit-nonzero falls through so message-regex still detects auth',
);
assert.strictEqual(t.classifyGenerationError(new Error('API returned HTTP 429')), 'rate-limit'); assert.strictEqual(t.classifyGenerationError(new Error('API returned HTTP 429')), 'rate-limit');
assert.strictEqual(t.classifyGenerationError(new Error('json_schema validation failed')), 'schema'); assert.strictEqual(t.classifyGenerationError(new Error('json_schema validation failed')), 'schema');
assert.strictEqual(t.classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema'); assert.strictEqual(t.classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
assert.strictEqual(
t.classifyGenerationError(new Error('LLM returned non-JSON (123 chars). See console for excerpt.')),
'schema',
'EN non-JSON message classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI returned unexpected output (200 chars).')),
'schema',
'EN unexpected output classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI returned no result (0 chars).')),
'schema',
'EN no result classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI 返回了非预期输出(长度 200 字符)')),
'schema',
'zh unexpected-output message classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI 没有返回结果(长度 0 字符)')),
'schema',
'zh no-result message classified as schema',
);
assert.strictEqual(t.classifyGenerationError(new Error('bad config value')), 'config'); assert.strictEqual(t.classifyGenerationError(new Error('bad config value')), 'config');
assert.strictEqual(t.classifyGenerationError(new Error('random error')), 'unknown'); assert.strictEqual(t.classifyGenerationError(new Error('random error')), 'unknown');
assert.strictEqual(t.classifyGenerationError(null), 'unknown', 'null error'); assert.strictEqual(t.classifyGenerationError(null), 'unknown', 'null error');
@ -219,7 +774,14 @@ assert.strictEqual(t.classifyGenerationError('string error'), 'unknown', 'string
(async () => { (async () => {
await testRunCliEdgeCases(); await testRunCliEdgeCases();
await testClaudeCodeArgs(); await testClaudeCodeArgs();
await testClaudeCodeStreamJsonOutput();
await testClaudeCodeStreamJsonResilience();
await testClaudeCodeVerboseRegressionCanary();
await testClaudeCodeArgsWithModel();
await testCodexArgs(); await testCodexArgs();
await testCodexArgsIgnoresClaudeDefaultModel();
await testCodexArgsMinimalContract();
await testCodexErrorPath();
console.log('cli tests passed'); console.log('cli tests passed');
})().catch((e) => { })().catch((e) => {
console.error(e); console.error(e);

View file

@ -0,0 +1,42 @@
'use strict';
// esbuild emits inline source maps whose `sources` are relative to the bundle
// file (deep under .test-bundles/) with a `sourceRoot`. v8-to-istanbul / the
// source-map library resolve those inconsistently and produce wrong paths like
// `/Users/.../github.com/src/batch.ts`, so c8 finds nothing to report.
//
// This rewrites the inline map in-place so every `source` is an absolute path
// to the real repo file and `sourceRoot` is cleared — making the remap exact.
// No-op when coverage is not active.
const fs = require('fs');
const path = require('path');
const MARKER = '//# sourceMappingURL=data:application/json;base64,';
function fixInlineSourceMap(outfile, repoRoot) {
if (!process.env.NODE_V8_COVERAGE) return;
const code = fs.readFileSync(outfile, 'utf8');
const idx = code.lastIndexOf(MARKER);
if (idx === -1) return;
const b64 = code.slice(idx + MARKER.length).trim();
const map = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
// esbuild keeps `sources` relative to the bundle file's directory regardless
// of `sourceRoot`; resolving against sourceRoot double-counts and corrupts
// the path. Always resolve against the bundle directory.
const base = path.dirname(outfile);
map.sources = map.sources.map((src) => {
if (src.startsWith('stub:') || src.includes('obsidian-stub')) return src;
const abs = path.resolve(base, src);
// Strip any leftover climbing artifacts; keep the real repo path.
const i = abs.indexOf(repoRoot);
return i === -1 ? abs : abs.slice(i);
});
delete map.sourceRoot;
const fixed = Buffer.from(JSON.stringify(map), 'utf8').toString('base64');
fs.writeFileSync(outfile, code.slice(0, idx) + MARKER + fixed);
}
module.exports = { fixInlineSourceMap };

View file

@ -5,11 +5,31 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const batch = await requireBundledModule('src/batch.ts'); const batch = await requireBundledModule('src/batch.ts');
// ── isFileInBatchFolder (not tested directly before) ── // ── isFileInBatchFolder (not tested directly before) ──
assert.strictEqual(batch.isFileInBatchFolder({ path: 'root.md', parent: null }, ''), true, 'root file in root folder'); assert.strictEqual(
assert.strictEqual(batch.isFileInBatchFolder({ path: 'sub/note.md', parent: { path: 'sub' } }, ''), false, 'subfolder file NOT in root folder'); batch.isFileInBatchFolder({ path: 'root.md', parent: null }, ''),
assert.strictEqual(batch.isFileInBatchFolder({ path: 'sub/note.md', parent: { path: 'sub' } }, 'sub'), true, 'file in matching folder'); true,
assert.strictEqual(batch.isFileInBatchFolder({ path: 'sub/deep/note.md', parent: { path: 'sub/deep' } }, 'sub'), false, 'nested file NOT in parent'); 'root file in root folder',
assert.strictEqual(batch.isFileInBatchFolder({ path: 'note.md', parent: undefined }, ''), true, 'undefined parent in root'); );
assert.strictEqual(
batch.isFileInBatchFolder({ path: 'sub/note.md', parent: { path: 'sub' } }, ''),
false,
'subfolder file NOT in root folder',
);
assert.strictEqual(
batch.isFileInBatchFolder({ path: 'sub/note.md', parent: { path: 'sub' } }, 'sub'),
true,
'file in matching folder',
);
assert.strictEqual(
batch.isFileInBatchFolder({ path: 'sub/deep/note.md', parent: { path: 'sub/deep' } }, 'sub'),
false,
'nested file NOT in parent',
);
assert.strictEqual(
batch.isFileInBatchFolder({ path: 'note.md', parent: undefined }, ''),
true,
'undefined parent in root',
);
// ── normalizeBatchFolderInput edge cases ── // ── normalizeBatchFolderInput edge cases ──
assert.strictEqual(batch.normalizeBatchFolderInput(null), '', 'null input'); assert.strictEqual(batch.normalizeBatchFolderInput(null), '', 'null input');
@ -57,4 +77,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -8,7 +8,9 @@ function createFakeAdapter() {
dirs, dirs,
writes: [], writes: [],
exists: async (filePath) => dirs.has(filePath) || files.has(filePath), exists: async (filePath) => dirs.has(filePath) || files.has(filePath),
mkdir: async (filePath) => { dirs.add(filePath); }, mkdir: async (filePath) => {
dirs.add(filePath);
},
read: async (filePath) => { read: async (filePath) => {
if (!files.has(filePath)) throw new Error('not found'); if (!files.has(filePath)) throw new Error('not found');
return files.get(filePath); return files.get(filePath);
@ -33,19 +35,52 @@ function createFakeAdapter() {
assert.strictEqual(cacheEntry.lastAccessedAt, undefined, 'direct cache import keeps cache entries immutable'); assert.strictEqual(cacheEntry.lastAccessedAt, undefined, 'direct cache import keeps cache entries immutable');
assert.strictEqual(JSON.parse(cache.serializeCacheFile({ 'a.md': { cards: [] } })).version, 1); assert.strictEqual(JSON.parse(cache.serializeCacheFile({ 'a.md': { cards: [] } })).version, 1);
// touchCacheEntry: null entry short-circuits to null
assert.strictEqual(cache.touchCacheEntry(null), null, 'touchCacheEntry returns null for null entry');
// touchCacheEntry: omitted `now` falls back to a generated ISO timestamp
const touchedDefault = cache.touchCacheEntry({ generatedAt: '2024-01-01T00:00:00.000Z' });
assert.ok(
/^\d{4}-\d{2}-\d{2}T/.test(touchedDefault.lastAccessedAt),
'touchCacheEntry defaults lastAccessedAt to current ISO time',
);
// serializeCacheFile: falsy entries map serializes to an empty object
assert.deepStrictEqual(
JSON.parse(cache.serializeCacheFile(null)).entries,
{},
'serializeCacheFile coerces falsy entries to {}',
);
// shouldConfirmRegenerate: every guard branch
assert.strictEqual(cache.shouldConfirmRegenerate(null, true), false, 'no entry → false');
assert.strictEqual(cache.shouldConfirmRegenerate({ updatedAt: 'x' }, false), false, 'force=false → false');
assert.strictEqual(cache.shouldConfirmRegenerate({}, true), false, 'missing updatedAt → false');
assert.strictEqual(cache.shouldConfirmRegenerate({ updatedAt: ' ' }, true), false, 'blank updatedAt → false');
assert.strictEqual(
cache.shouldConfirmRegenerate({ updatedAt: '2024-01-01' }, true),
true,
'force + non-blank updatedAt → true',
);
// ── CacheManager: load + prune ── // ── CacheManager: load + prune ──
const adapter = createFakeAdapter(); const adapter = createFakeAdapter();
const manager = new cacheManagerModule.CacheManager(adapter, '.obsidian', 'parallel-reader', () => ({ const manager = new cacheManagerModule.CacheManager(adapter, '.obsidian', 'parallel-reader', () => ({
...settings.DEFAULT_SETTINGS, maxCacheEntries: 2, ...settings.DEFAULT_SETTINGS,
})); maxCacheEntries: 2,
adapter.files.set(manager.filePath(), JSON.stringify({
version: 1,
entries: {
'old.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [] },
'fresh.md': { generatedAt: '2024-01-02T00:00:00.000Z', cards: [] },
'touched.md': { generatedAt: '2024-01-03T00:00:00.000Z', lastAccessedAt: '2024-02-01T00:00:00.000Z', cards: [] },
},
})); }));
adapter.files.set(
manager.filePath(),
JSON.stringify({
version: 1,
entries: {
'old.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [] },
'fresh.md': { generatedAt: '2024-01-02T00:00:00.000Z', cards: [] },
'touched.md': {
generatedAt: '2024-01-03T00:00:00.000Z',
lastAccessedAt: '2024-02-01T00:00:00.000Z',
cards: [],
},
},
}),
);
await manager.load(); await manager.load();
assert.strictEqual(manager.cache['old.md'], undefined, 'CacheManager.load prunes old entries'); assert.strictEqual(manager.cache['old.md'], undefined, 'CacheManager.load prunes old entries');
assert.ok(adapter.files.get(manager.filePath()).includes('fresh.md'), 'CacheManager.load persists prune results'); assert.ok(adapter.files.get(manager.filePath()).includes('fresh.md'), 'CacheManager.load persists prune results');
@ -56,8 +91,11 @@ function createFakeAdapter() {
assert.ok(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].lastAccessedAt); assert.ok(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].lastAccessedAt);
assert.strictEqual( assert.strictEqual(
await manager.replaceCards('fresh.md', [{ title: 'New', anchor: 'A', gist: 'G', bullets: ['B'], level: 2, startLine: 1 }]), await manager.replaceCards('fresh.md', [
true, 'CacheManager.replaceCards updates existing entries', { title: 'New', anchor: 'A', gist: 'G', bullets: ['B'], level: 2, startLine: 1 },
]),
true,
'CacheManager.replaceCards updates existing entries',
); );
assert.strictEqual(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].cards[0].title, 'New'); assert.strictEqual(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].cards[0].title, 'New');
@ -73,14 +111,34 @@ function createFakeAdapter() {
// ── Cache pruning interleaved with put ── // ── Cache pruning interleaved with put ──
const pruneAdapter = createFakeAdapter(); const pruneAdapter = createFakeAdapter();
const pruneManager = new cacheManagerModule.CacheManager(pruneAdapter, '.obsidian', 'parallel-reader', () => ({ const pruneManager = new cacheManagerModule.CacheManager(pruneAdapter, '.obsidian', 'parallel-reader', () => ({
...settings.DEFAULT_SETTINGS, maxCacheEntries: 2, ...settings.DEFAULT_SETTINGS,
maxCacheEntries: 2,
})); }));
await pruneManager.load(); await pruneManager.load();
pruneManager.cache = { pruneManager.cache = {
'old.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [], generatedAt: '2024-01-01T00:00:00.000Z', lastAccessedAt: '2024-01-01T00:00:00.000Z' }, 'old.md': {
'mid.md': { schemaVersion: 2, contentHash: 'b', settingsHash: 'b', cards: [], generatedAt: '2024-06-01T00:00:00.000Z', lastAccessedAt: '2024-06-01T00:00:00.000Z' }, schemaVersion: 2,
contentHash: 'a',
settingsHash: 'a',
cards: [],
generatedAt: '2024-01-01T00:00:00.000Z',
lastAccessedAt: '2024-01-01T00:00:00.000Z',
},
'mid.md': {
schemaVersion: 2,
contentHash: 'b',
settingsHash: 'b',
cards: [],
generatedAt: '2024-06-01T00:00:00.000Z',
lastAccessedAt: '2024-06-01T00:00:00.000Z',
},
}; };
await pruneManager.put('new.md', 'new content', [{ title: 'N', anchor: 'n', gist: 'g', bullets: [] }], settings.DEFAULT_SETTINGS); await pruneManager.put(
'new.md',
'new content',
[{ title: 'N', anchor: 'n', gist: 'g', bullets: [] }],
settings.DEFAULT_SETTINGS,
);
assert.strictEqual(Object.keys(pruneManager.cache).length, 2, 'cache pruned to max entries after put'); assert.strictEqual(Object.keys(pruneManager.cache).length, 2, 'cache pruned to max entries after put');
assert.ok(pruneManager.cache['new.md'], 'newest entry survives pruning'); assert.ok(pruneManager.cache['new.md'], 'newest entry survives pruning');
assert.strictEqual(pruneManager.cache['old.md'], undefined, 'oldest entry pruned by timestamp'); assert.strictEqual(pruneManager.cache['old.md'], undefined, 'oldest entry pruned by timestamp');
@ -93,11 +151,28 @@ function createFakeAdapter() {
// ── CacheManager.move() ── // ── CacheManager.move() ──
const moveAdapter = createFakeAdapter(); const moveAdapter = createFakeAdapter();
const moveManager = new cacheManagerModule.CacheManager(moveAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS); const moveManager = new cacheManagerModule.CacheManager(
moveAdapter,
'.obsidian',
'parallel-reader',
() => settings.DEFAULT_SETTINGS,
);
await moveManager.load(); await moveManager.load();
moveManager.cache = { moveManager.cache = {
'a.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [{ title: 'A', anchor: 'a', gist: 'g', bullets: [] }], generatedAt: '2024-01-01T00:00:00.000Z' }, 'a.md': {
'b.md': { schemaVersion: 2, contentHash: 'b', settingsHash: 'b', cards: [], generatedAt: '2024-01-02T00:00:00.000Z' }, schemaVersion: 2,
contentHash: 'a',
settingsHash: 'a',
cards: [{ title: 'A', anchor: 'a', gist: 'g', bullets: [] }],
generatedAt: '2024-01-01T00:00:00.000Z',
},
'b.md': {
schemaVersion: 2,
contentHash: 'b',
settingsHash: 'b',
cards: [],
generatedAt: '2024-01-02T00:00:00.000Z',
},
}; };
await moveManager.save(); await moveManager.save();
assert.strictEqual(await moveManager.move('a.md', 'renamed.md'), true, 'move returns true on success'); assert.strictEqual(await moveManager.move('a.md', 'renamed.md'), true, 'move returns true on success');
@ -111,24 +186,58 @@ function createFakeAdapter() {
// ── CacheManager.readFile() with corrupt JSON ── // ── CacheManager.readFile() with corrupt JSON ──
const corruptAdapter = createFakeAdapter(); const corruptAdapter = createFakeAdapter();
const corruptManager = new cacheManagerModule.CacheManager(corruptAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS); const corruptManager = new cacheManagerModule.CacheManager(
corruptAdapter,
'.obsidian',
'parallel-reader',
() => settings.DEFAULT_SETTINGS,
);
corruptAdapter.files.set(corruptManager.filePath(), '{ invalid json !!!'); corruptAdapter.files.set(corruptManager.filePath(), '{ invalid json !!!');
assert.deepStrictEqual(await corruptManager.readFile(), {}, 'readFile returns empty object for corrupt JSON'); assert.deepStrictEqual(await corruptManager.readFile(), {}, 'readFile returns empty object for corrupt JSON');
const noEntriesAdapter = createFakeAdapter(); const noEntriesAdapter = createFakeAdapter();
const noEntriesManager = new cacheManagerModule.CacheManager(noEntriesAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS); const noEntriesManager = new cacheManagerModule.CacheManager(
noEntriesAdapter,
'.obsidian',
'parallel-reader',
() => settings.DEFAULT_SETTINGS,
);
noEntriesAdapter.files.set(noEntriesManager.filePath(), JSON.stringify({ version: 1 })); noEntriesAdapter.files.set(noEntriesManager.filePath(), JSON.stringify({ version: 1 }));
assert.deepStrictEqual(await noEntriesManager.readFile(), {}, 'readFile returns empty object when no entries field'); assert.deepStrictEqual(
await noEntriesManager.readFile(),
{},
'readFile returns empty object when no entries field',
);
// ── CacheManager.pruneIfNeeded() ── // ── CacheManager.pruneIfNeeded() ──
const pruneNeededAdapter = createFakeAdapter(); const pruneNeededAdapter = createFakeAdapter();
const pruneNeededManager = new cacheManagerModule.CacheManager(pruneNeededAdapter, '.obsidian', 'parallel-reader', () => ({ const pruneNeededManager = new cacheManagerModule.CacheManager(
...settings.DEFAULT_SETTINGS, maxCacheEntries: 1, pruneNeededAdapter,
})); '.obsidian',
'parallel-reader',
() => ({
...settings.DEFAULT_SETTINGS,
maxCacheEntries: 1,
}),
);
await pruneNeededManager.load(); await pruneNeededManager.load();
pruneNeededManager.cache = { pruneNeededManager.cache = {
'old.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [], generatedAt: '2024-01-01T00:00:00.000Z', lastAccessedAt: '2024-01-01T00:00:00.000Z' }, 'old.md': {
'new.md': { schemaVersion: 2, contentHash: 'b', settingsHash: 'b', cards: [], generatedAt: '2024-06-01T00:00:00.000Z', lastAccessedAt: '2024-06-01T00:00:00.000Z' }, schemaVersion: 2,
contentHash: 'a',
settingsHash: 'a',
cards: [],
generatedAt: '2024-01-01T00:00:00.000Z',
lastAccessedAt: '2024-01-01T00:00:00.000Z',
},
'new.md': {
schemaVersion: 2,
contentHash: 'b',
settingsHash: 'b',
cards: [],
generatedAt: '2024-06-01T00:00:00.000Z',
lastAccessedAt: '2024-06-01T00:00:00.000Z',
},
}; };
const pruneIfResult = await pruneNeededManager.pruneIfNeeded(); const pruneIfResult = await pruneNeededManager.pruneIfNeeded();
assert.strictEqual(pruneIfResult.length, 1, 'pruneIfNeeded returns removed keys'); assert.strictEqual(pruneIfResult.length, 1, 'pruneIfNeeded returns removed keys');
@ -136,29 +245,128 @@ function createFakeAdapter() {
const noPruneAdapter = createFakeAdapter(); const noPruneAdapter = createFakeAdapter();
const noPruneManager = new cacheManagerModule.CacheManager(noPruneAdapter, '.obsidian', 'parallel-reader', () => ({ const noPruneManager = new cacheManagerModule.CacheManager(noPruneAdapter, '.obsidian', 'parallel-reader', () => ({
...settings.DEFAULT_SETTINGS, maxCacheEntries: 100, ...settings.DEFAULT_SETTINGS,
maxCacheEntries: 100,
})); }));
await noPruneManager.load(); await noPruneManager.load();
noPruneManager.cache = { 'only.md': { schemaVersion: 2, contentHash: 'a', settingsHash: 'a', cards: [], generatedAt: '2024-01-01T00:00:00.000Z' } }; noPruneManager.cache = {
assert.strictEqual((await noPruneManager.pruneIfNeeded()).length, 0, 'pruneIfNeeded returns empty when nothing to prune'); 'only.md': {
schemaVersion: 2,
contentHash: 'a',
settingsHash: 'a',
cards: [],
generatedAt: '2024-01-01T00:00:00.000Z',
},
};
assert.strictEqual(
(await noPruneManager.pruneIfNeeded()).length,
0,
'pruneIfNeeded returns empty when nothing to prune',
);
assert.strictEqual(await pruneNeededManager.replaceCards('nonexistent.md', []), false, 'replaceCards returns false for missing entry'); assert.strictEqual(
await pruneNeededManager.replaceCards('nonexistent.md', []),
false,
'replaceCards returns false for missing entry',
);
// ── CacheManager.scheduleSave() + flush() ── // ── CacheManager.scheduleSave() + flush() ──
const scheduleAdapter = createFakeAdapter(); const scheduleAdapter = createFakeAdapter();
const scheduleManager = new cacheManagerModule.CacheManager(scheduleAdapter, '.obsidian', 'parallel-reader', () => settings.DEFAULT_SETTINGS); const scheduleManager = new cacheManagerModule.CacheManager(
scheduleAdapter,
'.obsidian',
'parallel-reader',
() => settings.DEFAULT_SETTINGS,
);
await scheduleManager.load(); await scheduleManager.load();
scheduleManager.cache = { 'sched.md': { schemaVersion: 2, contentHash: 'x', settingsHash: 'x', cards: [], generatedAt: '2024-01-01T00:00:00.000Z' } }; scheduleManager.cache = {
'sched.md': {
schemaVersion: 2,
contentHash: 'x',
settingsHash: 'x',
cards: [],
generatedAt: '2024-01-01T00:00:00.000Z',
},
};
scheduleManager.scheduleSave(50000); scheduleManager.scheduleSave(50000);
await scheduleManager.flush(); await scheduleManager.flush();
assert.ok(JSON.parse(scheduleAdapter.files.get(scheduleManager.filePath())).entries['sched.md'], 'flush persists scheduled save'); assert.ok(
JSON.parse(scheduleAdapter.files.get(scheduleManager.filePath())).entries['sched.md'],
'flush persists scheduled save',
);
scheduleAdapter.files.delete(scheduleManager.filePath()); scheduleAdapter.files.delete(scheduleManager.filePath());
await scheduleManager.flush(); await scheduleManager.flush();
assert.strictEqual(scheduleAdapter.files.has(scheduleManager.filePath()), false, 'flush is no-op when not dirty'); assert.strictEqual(scheduleAdapter.files.has(scheduleManager.filePath()), false, 'flush is no-op when not dirty');
// ── readFile drops malformed entries (cards not array / bullets not array) ──
{
const validateAdapter = createFakeAdapter();
const validateManager = new cacheManagerModule.CacheManager(
validateAdapter,
'.obsidian',
'parallel-reader',
() => ({ ...settings.DEFAULT_SETTINGS, maxCacheEntries: 100 }),
);
validateAdapter.files.set(
validateManager.filePath(),
JSON.stringify({
version: 1,
entries: {
'good.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 't', bullets: ['x'] }] },
'cards-null.md': { cards: null },
'cards-string.md': { cards: 'oops' },
'bullets-string.md': { cards: [{ title: 't', bullets: 'not-array' }] },
'bullets-missing.md': { cards: [{ title: 't' }] }, // tolerated
'card-null.md': { cards: [null] }, // dangerous: c.anchor would crash
'anchor-number.md': { cards: [{ anchor: 42, bullets: [] }] }, // dangerous: anchor.trim() would crash
'not-an-object.md': 42,
'null-entry.md': null,
},
}),
);
const origWarn = console.warn;
let droppedMessage = '';
console.warn = (...args) => {
droppedMessage = args.join(' ');
};
try {
const loaded = await validateManager.readFile();
assert.ok(loaded['good.md'], 'good entry kept');
assert.ok(loaded['bullets-missing.md'], 'entry with missing bullets tolerated (defaults to [])');
assert.strictEqual(loaded['cards-null.md'], undefined, 'cards=null dropped');
assert.strictEqual(loaded['cards-string.md'], undefined, 'cards=string dropped');
assert.strictEqual(loaded['bullets-string.md'], undefined, 'bullets=string dropped');
assert.strictEqual(loaded['card-null.md'], undefined, 'cards=[null] dropped');
assert.strictEqual(loaded['anchor-number.md'], undefined, 'anchor=number dropped');
assert.strictEqual(loaded['not-an-object.md'], undefined, 'non-object entry dropped');
assert.strictEqual(loaded['null-entry.md'], undefined, 'null entry dropped');
assert.ok(/dropped 7 malformed/.test(droppedMessage), 'console.warn reports drop count');
} finally {
console.warn = origWarn;
}
}
// ── readFile tolerates parsed=null / non-object entries field ──
{
const edgeAdapter = createFakeAdapter();
const edgeManager = new cacheManagerModule.CacheManager(
edgeAdapter,
'.obsidian',
'parallel-reader',
() => settings.DEFAULT_SETTINGS,
);
edgeAdapter.files.set(edgeManager.filePath(), JSON.stringify(null));
assert.deepStrictEqual(await edgeManager.readFile(), {}, 'parsed=null returns empty cache');
edgeAdapter.files.set(edgeManager.filePath(), JSON.stringify({ entries: 'oops' }));
assert.deepStrictEqual(await edgeManager.readFile(), {}, 'entries=non-object returns empty cache');
}
console.log('direct cache tests passed'); console.log('direct cache tests passed');
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -61,14 +61,13 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
'cancelRequested', 'cancelRequested',
'null job = simple cancel', 'null job = simple cancel',
); );
assert.strictEqual( assert.strictEqual(generation.cancellationNoticeKey(null, null), 'cancelRequested', 'both null = simple cancel');
generation.cancellationNoticeKey(null, null),
'cancelRequested',
'both null = simple cancel',
);
console.log('direct generation tests passed'); console.log('direct generation tests passed');
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -6,10 +6,20 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'displayName'), 'Parallel Reader'); assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'displayName'), 'Parallel Reader');
assert.strictEqual(i18n.translate({ uiLanguage: 'zh' }, 'displayName'), '对照阅读笔记'); assert.strictEqual(i18n.translate({ uiLanguage: 'zh' }, 'displayName'), '对照阅读笔记');
assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'cacheClearedFile', { name: 'test.md' }), 'Cleared cache: test.md');
assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'generationDone', { count: 5, suffix: '' }), 'Generated 5 sections');
assert.strictEqual( assert.strictEqual(
i18n.translate({ uiLanguage: 'en' }, 'errorProviderApiStatus', { label: 'OpenAI', status: 429, excerpt: 'rate limited' }), i18n.translate({ uiLanguage: 'en' }, 'cacheClearedFile', { name: 'test.md' }),
'Cleared cache: test.md',
);
assert.strictEqual(
i18n.translate({ uiLanguage: 'en' }, 'generationDone', { count: 5, suffix: '' }),
'Generated 5 sections',
);
assert.strictEqual(
i18n.translate({ uiLanguage: 'en' }, 'errorProviderApiStatus', {
label: 'OpenAI',
status: 429,
excerpt: 'rate limited',
}),
'OpenAI API returned HTTP 429: rate limited', 'OpenAI API returned HTTP 429: rate limited',
); );
assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'nonExistentKey123'), 'nonExistentKey123'); assert.strictEqual(i18n.translate({ uiLanguage: 'en' }, 'nonExistentKey123'), 'nonExistentKey123');
@ -18,20 +28,72 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'en' }), 'en'); assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'en' }), 'en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'zh' }), 'zh'); assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'zh' }), 'zh');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'ja' }), 'ja');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'ko' }), 'ko');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'fr' }), 'fr');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'de' }), 'de');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'es' }), 'es');
assert.strictEqual(i18n.resolveUiLanguage(null), 'en'); assert.strictEqual(i18n.resolveUiLanguage(null), 'en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en'); assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: { toString: () => 'ja' } }), 'en');
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const setNavigatorLanguage = (language) => {
Object.defineProperty(globalThis, 'navigator', {
value: { language },
configurable: true,
});
};
try {
setNavigatorLanguage('ja-JP');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'ja');
setNavigatorLanguage('ko-KR');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'ko');
setNavigatorLanguage('fr-CA');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'fr');
setNavigatorLanguage('de_DE');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'de');
setNavigatorLanguage('es-MX');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'es');
setNavigatorLanguage('it-IT');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en');
} finally {
if (originalNavigator) {
Object.defineProperty(globalThis, 'navigator', originalNavigator);
} else {
delete globalThis.navigator;
}
}
assert.ok(i18n.STRINGS.zh); assert.ok(i18n.STRINGS.zh);
assert.ok(i18n.STRINGS.en); assert.ok(i18n.STRINGS.en);
const zhKeys = Object.keys(i18n.STRINGS.zh); assert.ok(i18n.STRINGS.ja);
const enKeys = Object.keys(i18n.STRINGS.en); assert.ok(i18n.STRINGS.ko);
assert.strictEqual(zhKeys.length, enKeys.length); assert.ok(i18n.STRINGS.fr);
for (const key of zhKeys) { assert.ok(i18n.STRINGS.de);
assert.ok(i18n.STRINGS.en[key], `en missing key: ${key}`); assert.ok(i18n.STRINGS.es);
const supportedLocales = ['zh', 'en', 'ja', 'ko', 'fr', 'de', 'es'];
const enKeys = Object.keys(i18n.STRINGS.en).sort();
for (const locale of supportedLocales) {
const keys = Object.keys(i18n.STRINGS[locale]).sort();
assert.deepStrictEqual(keys, enKeys, `${locale} keys must match en`);
for (const key of enKeys) {
assert.ok(i18n.STRINGS[locale][key], `${locale} empty key: ${key}`);
}
}
for (const locale of ['ja', 'ko', 'fr', 'de', 'es']) {
const overrideKeys = Object.keys(i18n.LOCALE_OVERRIDES[locale]).sort();
assert.deepStrictEqual(overrideKeys, enKeys, `${locale} raw override keys must match en`);
for (const key of enKeys) {
assert.ok(i18n.LOCALE_OVERRIDES[locale][key], `${locale} empty raw override: ${key}`);
}
} }
console.log('direct i18n tests passed'); console.log('direct i18n tests passed');
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -29,7 +29,12 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.ok(noGist.includes('> a'), 'anchor present'); assert.ok(noGist.includes('> a'), 'anchor present');
assert.ok(noGist.includes('- b'), 'bullets present'); assert.ok(noGist.includes('- b'), 'bullets present');
const anchorWhitespace = md.cardToMarkdown({ title: 'E', anchor: ' spaces \n tabs \t here ', gist: '', bullets: [] }); const anchorWhitespace = md.cardToMarkdown({
title: 'E',
anchor: ' spaces \n tabs \t here ',
gist: '',
bullets: [],
});
assert.ok(anchorWhitespace.includes('> spaces tabs here'), 'anchor whitespace normalized'); assert.ok(anchorWhitespace.includes('> spaces tabs here'), 'anchor whitespace normalized');
// ── cardToPlain ── // ── cardToPlain ──
@ -55,6 +60,9 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const plainMinimal = md.cardToPlain({ title: 'T', anchor: '', gist: '', bullets: [] }); const plainMinimal = md.cardToPlain({ title: 'T', anchor: '', gist: '', bullets: [] });
assert.strictEqual(plainMinimal, 'T', 'title only when gist and bullets empty'); assert.strictEqual(plainMinimal, 'T', 'title only when gist and bullets empty');
const plainNullBullets = md.cardToPlain({ title: 'T', anchor: '', gist: 'G' });
assert.strictEqual(plainNullBullets, 'T\nG', 'undefined bullets coerced to [] (no crash)');
// ── cardsToMarkdown ── // ── cardsToMarkdown ──
const multi = md.cardsToMarkdown('My Title', [ const multi = md.cardsToMarkdown('My Title', [
{ title: 'A', anchor: 'q', gist: 'g', bullets: ['b'] }, { title: 'A', anchor: 'q', gist: 'g', bullets: ['b'] },
@ -77,4 +85,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -9,8 +9,20 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
// ── promptLanguageInstruction ── // ── promptLanguageInstruction ──
assert.strictEqual(prompt.promptLanguageInstruction('en'), 'Write title, gist, and bullets in English.'); assert.strictEqual(prompt.promptLanguageInstruction('en'), 'Write title, gist, and bullets in English.');
assert.strictEqual(prompt.promptLanguageInstruction('zh'), '用中文输出 title、gist 和 bullets。'); assert.strictEqual(prompt.promptLanguageInstruction('zh'), '用中文输出 title、gist 和 bullets。');
assert.strictEqual(prompt.promptLanguageInstruction('auto'), 'Write title, gist, and bullets in the main language of the source document.'); assert.strictEqual(
assert.strictEqual(prompt.promptLanguageInstruction('fr'), '用中文输出 title、gist 和 bullets。', 'unknown language falls to zh'); prompt.promptLanguageInstruction('auto'),
'Write title, gist, and bullets in the main language of the source document.',
);
assert.strictEqual(prompt.promptLanguageInstruction('ja'), 'Write title, gist, and bullets in Japanese.');
assert.strictEqual(prompt.promptLanguageInstruction('ko'), 'Write title, gist, and bullets in Korean.');
assert.strictEqual(prompt.promptLanguageInstruction('fr'), 'Write title, gist, and bullets in French.');
assert.strictEqual(prompt.promptLanguageInstruction('de'), 'Write title, gist, and bullets in German.');
assert.strictEqual(prompt.promptLanguageInstruction('es'), 'Write title, gist, and bullets in Spanish.');
assert.strictEqual(
prompt.promptLanguageInstruction('xx'),
'用中文输出 title、gist 和 bullets。',
'unknown language falls to zh',
);
// ── promptSchemaExample ── // ── promptSchemaExample ──
const enExample = prompt.promptSchemaExample('en'); const enExample = prompt.promptSchemaExample('en');
@ -22,6 +34,11 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.ok(zhExample.includes('"cards"'), 'zh example is JSON-like'); assert.ok(zhExample.includes('"cards"'), 'zh example is JSON-like');
assert.ok(prompt.promptSchemaExample('auto').includes('U 型收益曲线'), 'auto falls to zh example'); assert.ok(prompt.promptSchemaExample('auto').includes('U 型收益曲线'), 'auto falls to zh example');
assert.ok(prompt.promptSchemaExample('ja').includes('U字型の利益'), 'ja example has expected title');
assert.ok(prompt.promptSchemaExample('ko').includes('U자형 이득'), 'ko example has expected title');
assert.ok(prompt.promptSchemaExample('fr').includes('Gains en U'), 'fr example has expected title');
assert.ok(prompt.promptSchemaExample('de').includes('U-förmige Gewinne'), 'de example has expected title');
assert.ok(prompt.promptSchemaExample('es').includes('Ganancias en forma de U'), 'es example has expected title');
// ── renderPromptTemplate ── // ── renderPromptTemplate ──
assert.strictEqual(prompt.renderPromptTemplate('Hello {name}!', { name: 'World' }), 'Hello World!'); assert.strictEqual(prompt.renderPromptTemplate('Hello {name}!', { name: 'World' }), 'Hello World!');
@ -48,6 +65,19 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const autoResult = prompt.buildPrompts('content', { ...base, promptLanguage: 'auto' }); const autoResult = prompt.buildPrompts('content', { ...base, promptLanguage: 'auto' });
assert.ok(autoResult.system.includes('main language'), 'auto language instruction'); assert.ok(autoResult.system.includes('main language'), 'auto language instruction');
// ── buildPrompts: expanded output languages ──
for (const [language, expected] of [
['ja', 'Japanese'],
['ko', 'Korean'],
['fr', 'French'],
['de', 'German'],
['es', 'Spanish'],
]) {
const result = prompt.buildPrompts('content', { ...base, promptLanguage: language });
assert.ok(result.system.includes(expected), `${language} language instruction`);
assert.ok(result.user.includes('Source document:'), `${language} uses provider-facing source prefix`);
}
// ── buildPrompts: truncation ── // ── buildPrompts: truncation ──
const longDoc = 'x'.repeat(25000); const longDoc = 'x'.repeat(25000);
const truncResult = prompt.buildPrompts(longDoc, { ...base, maxDocChars: 20000 }); const truncResult = prompt.buildPrompts(longDoc, { ...base, maxDocChars: 20000 });
@ -74,14 +104,16 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const wsCustom = prompt.buildPrompts('doc', { ...base, customSystemPrompt: ' ' }); const wsCustom = prompt.buildPrompts('doc', { ...base, customSystemPrompt: ' ' });
assert.ok(wsCustom.system.includes('long-form reading'), 'default prompt used when custom is whitespace-only'); assert.ok(wsCustom.system.includes('long-form reading'), 'default prompt used when custom is whitespace-only');
// ── buildPrompts: invalid promptLanguage falls back ── // ── buildPrompts: invalid promptLanguage falls back to the default ('auto') ──
const badLang = prompt.buildPrompts('doc', { ...base, promptLanguage: 'xyz' }); const badLang = prompt.buildPrompts('doc', { ...base, promptLanguage: 'xyz' });
assert.ok(badLang.system.includes('中文'), 'invalid promptLanguage falls back to zh default'); const autoFallback = prompt.buildPrompts('doc', { ...base, promptLanguage: 'auto' });
assert.strictEqual(badLang.system, autoFallback.system, 'invalid promptLanguage falls back to the default (auto)');
assert.ok(badLang.system.includes('main language'), 'fallback uses the auto language instruction');
// ── buildPrompts: card count normalization ── // ── buildPrompts: card count normalization ──
// 0 is falsy so || picks DEFAULT (5), then Math.max(1, 5) = 5 // 0 is falsy so || picks DEFAULT (5), then Math.max(1, 5) = 5
const zeroCards = prompt.buildPrompts('doc', { ...base, minCards: 0, maxCards: 0 }); const zeroCards = prompt.buildPrompts('doc', { ...base, minCards: 0, maxCards: 0 });
assert.ok(zeroCards.system.includes('5-'), 'zero minCards falls back to default'); assert.ok(zeroCards.system.includes('1-1'), 'zero minCards/maxCards clamps to 1 (normalizeCardCount floor)');
const bigCards = prompt.buildPrompts('doc', { ...base, minCards: 5, maxCards: 3 }); const bigCards = prompt.buildPrompts('doc', { ...base, minCards: 5, maxCards: 3 });
assert.ok(bigCards.system.includes('5-5'), 'maxCards raised to match minCards'); assert.ok(bigCards.system.includes('5-5'), 'maxCards raised to match minCards');
@ -90,8 +122,17 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const defaultMax = prompt.buildPrompts('doc', { ...base, maxDocChars: undefined }); const defaultMax = prompt.buildPrompts('doc', { ...base, maxDocChars: undefined });
assert.ok(defaultMax.user.includes('doc'), 'undefined maxDocChars uses default without truncation'); assert.ok(defaultMax.user.includes('doc'), 'undefined maxDocChars uses default without truncation');
// ── buildPrompts: card count is normalized (capped at 30) — keeps prompt ↔ fingerprint in sync ──
const oversized = prompt.buildPrompts('doc', { ...base, minCards: 100, maxCards: 200 });
assert.ok(oversized.system.includes('30-30'), 'over-cap card count is clamped to 30 in prompt');
assert.ok(!oversized.system.includes('100'), 'raw 100 must not appear in prompt');
assert.ok(!oversized.system.includes('200'), 'raw 200 must not appear in prompt');
console.log('direct prompt tests passed'); console.log('direct prompt tests passed');
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -4,18 +4,46 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
try { try {
const generation = await requireBundledModule('src/generation.ts'); const generation = await requireBundledModule('src/generation.ts');
const providerParsers = await requireBundledModule('src/provider-parsers.ts'); const providerParsers = await requireBundledModule('src/provider-parsers.ts');
const providerRequest = await requireBundledModule('src/provider-request.ts');
// ── generation.ts ── // ── generation.ts ──
assert.strictEqual(generation.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }), 'cancelRequestedApiInFlight'); assert.strictEqual(
assert.strictEqual(generation.cancellationNoticeKey({ backend: 'codex' }, { phase: 'generating' }), 'cancelRequested'); generation.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
'cancelRequestedApiInFlight',
);
assert.strictEqual(
generation.cancellationNoticeKey({ backend: 'codex' }, { phase: 'generating' }),
'cancelRequested',
);
// ── provider-parsers.ts ── // ── provider-parsers.ts ──
const cardsJson = JSON.stringify({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] }); const cardsJson = JSON.stringify({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] });
assert.strictEqual(providerParsers.textFromOpenAiChatResponse({ choices: [{ message: { content: [{ text: cardsJson }] } }] }), cardsJson); assert.strictEqual(
assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: cardsJson }] }), cardsJson); providerParsers.textFromOpenAiChatResponse({ choices: [{ message: { content: [{ text: cardsJson }] } }] }),
assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({ output: [{ content: [{ type: 'output_text', text: cardsJson }] }] }), cardsJson); cardsJson,
assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({ candidates: [{ content: { parts: [{ text: cardsJson }] } }] }), cardsJson); );
assert.deepStrictEqual(providerParsers.cardsFromAnthropicToolUse({ content: [{ type: 'tool_use', name: 'record_parallel_reader_cards', input: JSON.parse(cardsJson) }] }), [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }]); assert.strictEqual(
providerParsers.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: cardsJson }] }),
cardsJson,
);
assert.strictEqual(
providerParsers.textFromOpenAiResponsesResponse({
output: [{ content: [{ type: 'output_text', text: cardsJson }] }],
}),
cardsJson,
);
assert.strictEqual(
providerParsers.textFromGoogleGenerativeAiResponse({
candidates: [{ content: { parts: [{ text: cardsJson }] } }],
}),
cardsJson,
);
assert.deepStrictEqual(
providerParsers.cardsFromAnthropicToolUse({
content: [{ type: 'tool_use', name: 'record_parallel_reader_cards', input: JSON.parse(cardsJson) }],
}),
[{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }],
);
// ── Provider parser edge cases ── // ── Provider parser edge cases ──
assert.strictEqual(providerParsers.textFromProviderContent('hello'), 'hello'); assert.strictEqual(providerParsers.textFromProviderContent('hello'), 'hello');
@ -31,22 +59,111 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({}), ''); assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({}), '');
assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({ content: [] }), ''); assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({ content: [] }), '');
assert.strictEqual(providerParsers.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }), 'ab'); assert.strictEqual(
providerParsers.textFromAnthropicMessagesResponse({
content: [
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' },
],
}),
'ab',
);
assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({}), ''); assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({}), '');
assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({ candidates: [] }), ''); assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({ candidates: [] }), '');
assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({ candidates: [{ content: {} }] }), ''); assert.strictEqual(providerParsers.textFromGoogleGenerativeAiResponse({ candidates: [{ content: {} }] }), '');
assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({ output_text: 'direct' }), 'direct'); assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({ output_text: 'direct' }), 'direct');
assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({ output: [{ content: [{ type: 'output_text', content: 'nested' }] }] }), 'nested'); assert.strictEqual(
providerParsers.textFromOpenAiResponsesResponse({
output: [{ content: [{ type: 'output_text', content: 'nested' }] }],
}),
'nested',
);
assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({}), ''); assert.strictEqual(providerParsers.textFromOpenAiResponsesResponse({}), '');
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({ content: [{ type: 'text', text: 'hello' }] }), null); assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({ content: [{ type: 'text', text: 'hello' }] }), null);
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({ content: [{ type: 'tool_use', name: 'other_tool', input: {} }] }), null); assert.strictEqual(
providerParsers.cardsFromAnthropicToolUse({ content: [{ type: 'tool_use', name: 'other_tool', input: {} }] }),
null,
);
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({}), null); assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({}), null);
// ── provider-request: structured-output fallback keyed on HTTP status, not localized text ──
const { ProviderApiError, shouldRetryWithoutStructuredOutput, requestJsonBodyWithStructuredFallback } =
providerRequest;
// 400 + body keyword ⇒ retry, regardless of the (i18n-translated) message language.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(
new ProviderApiError('英語以外のエラーメッセージ', 400, 'response_format is not supported'),
),
true,
'status-400 + body keyword ⇒ retry even when message is non-English/Chinese',
);
assert.strictEqual(
shouldRetryWithoutStructuredOutput(
new ProviderApiError("L'API a renvoyé une erreur", 422, 'json_schema unsupported'),
),
true,
'status-422 + body keyword ⇒ retry for a French-localized message',
);
// 5xx is a server error, never a structured-output problem ⇒ do not retry.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('server error', 500, 'response_format')),
false,
);
// 400 without any schema-related keyword ⇒ do not retry.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'missing required field: model')),
false,
);
// Model-name errors ("unknown model" / "unrecognized model ID") must NOT trigger a
// wasted fallback retry — bare unknown/unrecognized keywords were dropped for this.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unknown model: gpt-5')),
false,
);
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unrecognized model ID: x')),
false,
);
// Legacy path: a plain Error carrying the English template still works (back-compat).
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new Error('OpenAI API returned HTTP 400: response_format not supported')),
true,
);
assert.strictEqual(shouldRetryWithoutStructuredOutput(new Error('some unrelated error')), false);
// End-to-end: a 400 returned alongside a NON-English locale still triggers the fallback body.
{
const calls = [];
const fakeRequestUrl = async ({ body }) => {
calls.push(JSON.parse(body));
if (calls.length === 1) {
return { status: 400, json: null, text: 'response_format is not supported by this model' };
}
return { status: 200, json: { ok: true }, text: '' };
};
const result = await requestJsonBodyWithStructuredFallback(
fakeRequestUrl,
'OpenAI-compatible Chat',
'https://example.test',
{},
{ model: 'x', response_format: { type: 'json_schema' } },
{ model: 'x' },
{ uiLanguage: 'fr' }, // localized error message must NOT block the fallback
);
assert.deepStrictEqual(result, { ok: true });
assert.strictEqual(calls.length, 2, 'should retry exactly once without structured output');
assert.ok(!('response_format' in calls[1]), 'fallback body omits response_format');
}
console.log('direct providers tests passed'); console.log('direct providers tests passed');
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -4,6 +4,10 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
try { try {
const settings = await requireBundledModule('src/settings.ts'); const settings = await requireBundledModule('src/settings.ts');
const base = settings.DEFAULT_SETTINGS; const base = settings.DEFAULT_SETTINGS;
const supportedLanguageIds = ['auto', 'zh', 'en', 'ja', 'ko', 'fr', 'de', 'es'];
assert.deepStrictEqual(Object.keys(settings.PROMPT_LANGUAGES), supportedLanguageIds, 'prompt language order');
assert.deepStrictEqual(Object.keys(settings.UI_LANGUAGES), supportedLanguageIds, 'ui language order');
// ── stableStringify ── // ── stableStringify ──
assert.strictEqual(settings.stableStringify(42), '42', 'number'); assert.strictEqual(settings.stableStringify(42), '42', 'number');
@ -16,7 +20,11 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(settings.stableStringify({ b: 2, a: 1 }), '{"a":1,"b":2}', 'object keys sorted'); assert.strictEqual(settings.stableStringify({ b: 2, a: 1 }), '{"a":1,"b":2}', 'object keys sorted');
assert.strictEqual(settings.stableStringify({}), '{}', 'empty object'); assert.strictEqual(settings.stableStringify({}), '{}', 'empty object');
// Nested objects // Nested objects
assert.strictEqual(settings.stableStringify({ z: { b: 2, a: 1 }, a: 3 }), '{"a":3,"z":{"a":1,"b":2}}', 'nested keys sorted'); assert.strictEqual(
settings.stableStringify({ z: { b: 2, a: 1 }, a: 3 }),
'{"a":3,"z":{"a":1,"b":2}}',
'nested keys sorted',
);
// Same data different order produces same string // Same data different order produces same string
assert.strictEqual( assert.strictEqual(
settings.stableStringify({ x: 1, y: 2 }), settings.stableStringify({ x: 1, y: 2 }),
@ -49,11 +57,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
); );
// ── getApiAuthType ── // ── getApiAuthType ──
assert.strictEqual( assert.strictEqual(settings.getApiAuthType({ ...base, apiAuthType: 'bearer' }), 'bearer', 'explicit auth type');
settings.getApiAuthType({ ...base, apiAuthType: 'bearer' }),
'bearer',
'explicit auth type',
);
assert.strictEqual( assert.strictEqual(
settings.getApiAuthType({ ...base, apiProvider: 'anthropic', apiAuthType: 'auto' }), settings.getApiAuthType({ ...base, apiProvider: 'anthropic', apiAuthType: 'auto' }),
'x-api-key', 'x-api-key',
@ -99,6 +103,19 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(normed.maxDocChars, base.maxDocChars, 'maxDocChars <1000 → default'); assert.strictEqual(normed.maxDocChars, base.maxDocChars, 'maxDocChars <1000 → default');
assert.strictEqual(normed.customSystemPrompt, '', 'non-string customSystemPrompt → empty string'); assert.strictEqual(normed.customSystemPrompt, '', 'non-string customSystemPrompt → empty string');
for (const language of supportedLanguageIds) {
assert.strictEqual(
settings.normalizeSettings({ ...base, uiLanguage: language }).uiLanguage,
language,
`normalizeSettings preserves uiLanguage ${language}`,
);
assert.strictEqual(
settings.normalizeSettings({ ...base, promptLanguage: language }).promptLanguage,
language,
`normalizeSettings preserves promptLanguage ${language}`,
);
}
// ── pruneCacheEntries edge cases ── // ── pruneCacheEntries edge cases ──
assert.deepStrictEqual(settings.pruneCacheEntries(null, 10), [], 'null cache'); assert.deepStrictEqual(settings.pruneCacheEntries(null, 10), [], 'null cache');
assert.deepStrictEqual(settings.pruneCacheEntries({}, 10), [], 'empty cache'); assert.deepStrictEqual(settings.pruneCacheEntries({}, 10), [], 'empty cache');
@ -108,4 +125,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -48,8 +48,14 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const envVarName = '__PARALLEL_READER_TEST_KEY__'; const envVarName = '__PARALLEL_READER_TEST_KEY__';
process.env[envVarName] = 'env-key-val'; process.env[envVarName] = 'env-key-val';
try { try {
assert.strictEqual(settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: '', apiKeyEnvVar: envVarName }), 'env-key-val'); assert.strictEqual(
assert.strictEqual(settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: 'direct', apiKeyEnvVar: envVarName }), 'direct'); settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: '', apiKeyEnvVar: envVarName }),
'env-key-val',
);
assert.strictEqual(
settings.getApiKey({ ...settings.DEFAULT_SETTINGS, apiKey: 'direct', apiKeyEnvVar: envVarName }),
'direct',
);
} finally { } finally {
delete process.env[envVarName]; delete process.env[envVarName];
} }
@ -61,12 +67,19 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
schemaVersion: settings.CACHE_SCHEMA_VERSION, schemaVersion: settings.CACHE_SCHEMA_VERSION,
contentHash: settings.hashContent(testContent), contentHash: settings.hashContent(testContent),
settingsHash: settings.generationFingerprint(testSettings), settingsHash: settings.generationFingerprint(testSettings),
cards: [], generatedAt: '2024-01-01T00:00:00.000Z', cards: [],
generatedAt: '2024-01-01T00:00:00.000Z',
}; };
assert.strictEqual(settings.cacheEntryMatches(validEntry, testContent, testSettings), true); assert.strictEqual(settings.cacheEntryMatches(validEntry, testContent, testSettings), true);
assert.strictEqual(settings.cacheEntryMatches(null, testContent, testSettings), false); assert.strictEqual(settings.cacheEntryMatches(null, testContent, testSettings), false);
assert.strictEqual(settings.cacheEntryMatches({ ...validEntry, contentHash: 'wrong' }, testContent, testSettings), false); assert.strictEqual(
assert.strictEqual(settings.cacheEntryMatches({ ...validEntry, schemaVersion: 999 }, testContent, testSettings), false); settings.cacheEntryMatches({ ...validEntry, contentHash: 'wrong' }, testContent, testSettings),
false,
);
assert.strictEqual(
settings.cacheEntryMatches({ ...validEntry, schemaVersion: 999 }, testContent, testSettings),
false,
);
assert.strictEqual(settings.cacheEntryMatches(validEntry, 'different content', testSettings), false); assert.strictEqual(settings.cacheEntryMatches(validEntry, 'different content', testSettings), false);
// ── cards.ts: resolveCardAnchors ── // ── cards.ts: resolveCardAnchors ──
@ -100,4 +113,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -35,19 +35,31 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.deepStrictEqual(incomplete.deltas, []); assert.deepStrictEqual(incomplete.deltas, []);
assert.ok(incomplete.rest.length > 0); assert.ok(incomplete.rest.length > 0);
const withDone = streaming.parseSseBuffer('data: {"choices":[{"delta":{"content":"x"}}]}\n\ndata: [DONE]\n\n', openAiExtractor); const withDone = streaming.parseSseBuffer(
'data: {"choices":[{"delta":{"content":"x"}}]}\n\ndata: [DONE]\n\n',
openAiExtractor,
);
assert.deepStrictEqual(withDone.deltas, ['x']); assert.deepStrictEqual(withDone.deltas, ['x']);
const withComments = streaming.parseSseBuffer(': keep-alive\nevent: message\ndata: {"choices":[{"delta":{"content":"ok"}}]}\n\n', openAiExtractor); const withComments = streaming.parseSseBuffer(
': keep-alive\nevent: message\ndata: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
openAiExtractor,
);
assert.deepStrictEqual(withComments.deltas, ['ok']); assert.deepStrictEqual(withComments.deltas, ['ok']);
const crlf = streaming.parseSseBuffer('data: {"choices":[{"delta":{"content":"crlf"}}]}\r\n\r\n', openAiExtractor); const crlf = streaming.parseSseBuffer('data: {"choices":[{"delta":{"content":"crlf"}}]}\r\n\r\n', openAiExtractor);
assert.deepStrictEqual(crlf.deltas, ['crlf']); assert.deepStrictEqual(crlf.deltas, ['crlf']);
const multiLine = streaming.parseSseBuffer('data: {"choices":[{"delta":\ndata: {"content":"split"}}]}\n\n', openAiExtractor); const multiLine = streaming.parseSseBuffer(
'data: {"choices":[{"delta":\ndata: {"content":"split"}}]}\n\n',
openAiExtractor,
);
assert.deepStrictEqual(multiLine.deltas, ['split']); assert.deepStrictEqual(multiLine.deltas, ['split']);
const malformed = streaming.parseSseBuffer('data: not-json\n\ndata: {"choices":[{"delta":{"content":"ok"}}]}\n\n', openAiExtractor); const malformed = streaming.parseSseBuffer(
'data: not-json\n\ndata: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
openAiExtractor,
);
assert.deepStrictEqual(malformed.deltas, ['ok']); assert.deepStrictEqual(malformed.deltas, ['ok']);
const anthEvent = streaming.parseSseBuffer( const anthEvent = streaming.parseSseBuffer(
@ -62,68 +74,207 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const empty = streaming.parseSseBuffer('', openAiExtractor); const empty = streaming.parseSseBuffer('', openAiExtractor);
assert.deepStrictEqual(empty.deltas, []); assert.deepStrictEqual(empty.deltas, []);
// ── streamingFetch ── // ── streamErrorMessage: provider errors delivered as a 200-status SSE payload ──
assert.strictEqual(
streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }),
'Overloaded',
);
assert.strictEqual(
streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error' } }),
'overloaded_error',
);
assert.strictEqual(streaming.streamErrorMessage({ type: 'error' }), 'Provider returned a streaming error');
assert.strictEqual(streaming.streamErrorMessage({ error: { message: 'Quota exceeded' } }), 'Quota exceeded');
// OpenAI-style error object without a message/type is NOT treated as an error
// (avoids aborting a normal chunk that carries a stray empty/code-only `error`).
assert.strictEqual(streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), null);
assert.strictEqual(streaming.streamErrorMessage({ error: {} }), null);
assert.strictEqual(streaming.streamErrorMessage({ choices: [{ delta: { content: 'hi' } }] }), null);
assert.strictEqual(streaming.streamErrorMessage({ type: 'content_block_delta', delta: { text: 'x' } }), null);
assert.strictEqual(streaming.streamErrorMessage({ error: null }), null);
// ── parseSseBuffer: surface in-stream provider errors instead of swallowing them ──
assert.throws(
() =>
streaming.parseSseBuffer(
'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n',
anthropicExtractor,
),
/Overloaded/,
'anthropic in-stream error must throw, not be swallowed as a non-JSON line',
);
assert.throws(
() => streaming.parseSseBuffer('data: {"error":{"message":"Rate limit reached"}}\n\n', openAiExtractor),
/Rate limit reached/,
'openai-compatible in-stream error must throw',
);
// Normal deltas through the same parse path still work.
const okAfterError = streaming.parseSseBuffer(
'data: {"choices":[{"delta":{"content":"fine"}}]}\n\n',
openAiExtractor,
);
assert.deepStrictEqual(okAfterError.deltas, ['fine']);
// ── streamingRequestUrl ──
function trackedSignal() { function trackedSignal() {
const controller = new AbortController(); const controller = new AbortController();
const signal = controller.signal; const signal = controller.signal;
let activeListeners = 0; let activeListeners = 0;
const addEventListener = signal.addEventListener.bind(signal); const addEventListener = signal.addEventListener.bind(signal);
const removeEventListener = signal.removeEventListener.bind(signal); const removeEventListener = signal.removeEventListener.bind(signal);
signal.addEventListener = (type, listener, options) => { if (type === 'abort') activeListeners++; return addEventListener(type, listener, options); }; signal.addEventListener = (type, listener, options) => {
signal.removeEventListener = (type, listener, options) => { if (type === 'abort') activeListeners--; return removeEventListener(type, listener, options); }; if (type === 'abort') activeListeners++;
return addEventListener(type, listener, options);
};
signal.removeEventListener = (type, listener, options) => {
if (type === 'abort') activeListeners--;
return removeEventListener(type, listener, options);
};
return { controller, signal, activeListeners: () => activeListeners }; return { controller, signal, activeListeners: () => activeListeners };
} }
function streamingBody(text) { {
const encoder = new TextEncoder(); const success = trackedSignal();
return new ReadableStream({ start(c) { c.enqueue(encoder.encode(text)); c.close(); } }); const progress = [];
const text = await streaming.streamingRequestUrl(
async (params) => {
assert.strictEqual(params.method, 'POST');
assert.strictEqual(params.url, 'https://example.test');
assert.strictEqual(params.body, '{"stream":true}');
return {
status: 200,
json: null,
text: 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
};
},
'https://example.test',
{},
{ stream: true },
streaming.deltaExtractorForFormat('openai-chat'),
(p) => progress.push(p),
success.signal,
{ streamingTimeoutMs: 1000 },
);
assert.strictEqual(text, 'ok');
assert.deepStrictEqual(progress, [
{ accumulated: 'ok', done: false },
{ accumulated: 'ok', done: true },
]);
assert.strictEqual(success.activeListeners(), 0, 'cleanup after success');
} }
const originalFetch = globalThis.fetch; {
try {
const success = trackedSignal();
globalThis.fetch = async () => ({ ok: true, status: 200, body: streamingBody('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n'), text: async () => '' });
await streaming.streamingFetch('https://example.test', {}, {}, streaming.deltaExtractorForFormat('openai-chat'), undefined, success.signal, { streamingTimeoutMs: 1000 });
assert.strictEqual(success.activeListeners(), 0, 'cleanup after success');
const httpError = trackedSignal(); const httpError = trackedSignal();
globalThis.fetch = async () => ({ ok: false, status: 500, body: null, text: async () => 'bad' }); await assert.rejects(
await assert.rejects(() => streaming.streamingFetch('https://example.test', {}, {}, streaming.deltaExtractorForFormat('openai-chat'), undefined, httpError.signal, { streamingTimeoutMs: 1000 }), /HTTP 500|API returned HTTP 500/); () =>
streaming.streamingRequestUrl(
async () => ({ status: 500, json: null, text: 'bad' }),
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
httpError.signal,
{ streamingTimeoutMs: 1000 },
),
/HTTP 500|API returned HTTP 500/,
);
assert.strictEqual(httpError.activeListeners(), 0, 'cleanup after HTTP error'); assert.strictEqual(httpError.activeListeners(), 0, 'cleanup after HTTP error');
}
{
const timeout = trackedSignal(); const timeout = trackedSignal();
globalThis.fetch = async () => new Promise(() => {}); await assert.rejects(
await assert.rejects(() => streaming.streamingFetch('https://example.test', {}, {}, streaming.deltaExtractorForFormat('openai-chat'), undefined, timeout.signal, { streamingTimeoutMs: 1 }), /Streaming timed out/); () =>
streaming.streamingRequestUrl(
async () => new Promise(() => {}),
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
timeout.signal,
{ streamingTimeoutMs: 1 },
),
/Streaming timed out/,
);
assert.strictEqual(timeout.activeListeners(), 0, 'cleanup after timeout'); assert.strictEqual(timeout.activeListeners(), 0, 'cleanup after timeout');
}
{
const preAborted = trackedSignal(); const preAborted = trackedSignal();
preAborted.controller.abort(); preAborted.controller.abort();
globalThis.fetch = async (_url, opts) => { if (opts?.signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError'); throw new Error('should not reach'); }; let called = false;
await assert.rejects(() => streaming.streamingFetch('https://example.test', {}, {}, streaming.deltaExtractorForFormat('openai-chat'), undefined, preAborted.signal, { streamingTimeoutMs: 5000 }), /abort/i); await assert.rejects(
() =>
streaming.streamingRequestUrl(
async () => {
called = true;
return { status: 200, json: null, text: '' };
},
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
preAborted.signal,
{ streamingTimeoutMs: 5000 },
),
/abort/i,
);
assert.strictEqual(called, false, 'pre-aborted request is not started');
assert.strictEqual(preAborted.activeListeners(), 0, 'cleanup on pre-aborted'); assert.strictEqual(preAborted.activeListeners(), 0, 'cleanup on pre-aborted');
}
const abortDuringRead = trackedSignal(); {
globalThis.fetch = async (_url, opts) => { const abortDuringRequest = trackedSignal();
const fetchSignal = opts?.signal; await assert.rejects(
const stream = new ReadableStream({ () =>
async pull(ctrl) { streaming.streamingRequestUrl(
ctrl.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"first"}}]}\n\n')); async () =>
abortDuringRead.controller.abort(); new Promise((resolve) => {
await new Promise((r) => setTimeout(r, 5)); setTimeout(() => abortDuringRequest.controller.abort(), 1);
if (fetchSignal?.aborted) { ctrl.error(new DOMException('The operation was aborted.', 'AbortError')); return; } setTimeout(() => resolve({ status: 200, json: null, text: '' }), 20);
ctrl.close(); }),
}, 'https://example.test',
}); {},
return { ok: true, status: 200, body: stream, text: async () => '' }; {},
}; streaming.deltaExtractorForFormat('openai-chat'),
await assert.rejects(() => streaming.streamingFetch('https://example.test', {}, {}, streaming.deltaExtractorForFormat('openai-chat'), undefined, abortDuringRead.signal, { streamingTimeoutMs: 5000 }), /abort/i); undefined,
assert.strictEqual(abortDuringRead.activeListeners(), 0, 'cleanup after mid-read abort'); abortDuringRequest.signal,
} finally { { streamingTimeoutMs: 5000 },
globalThis.fetch = originalFetch; ),
/abort/i,
);
assert.strictEqual(abortDuringRequest.activeListeners(), 0, 'cleanup after mid-request abort');
}
{
const requestFailure = trackedSignal();
await assert.rejects(
() =>
streaming.streamingRequestUrl(
async () => {
throw new Error('network down');
},
'https://example.test',
{},
{},
streaming.deltaExtractorForFormat('openai-chat'),
undefined,
requestFailure.signal,
{ streamingTimeoutMs: 1000 },
),
/network down/,
);
assert.strictEqual(requestFailure.activeListeners(), 0, 'cleanup after transport failure');
} }
console.log('direct streaming tests passed'); console.log('direct streaming tests passed');
} finally { } finally {
cleanup(); cleanup();
} }
})().catch((e) => { console.error(e); process.exit(1); }); })().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -4,8 +4,18 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
// Polyfill Obsidian's `activeWindow` global for Node test runtime.
if (typeof globalThis.activeWindow === 'undefined') {
globalThis.activeWindow = globalThis;
}
const repoRoot = path.join(__dirname, '..'); const repoRoot = path.join(__dirname, '..');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-reader-tests-')); // Under c8, bundles must live inside the repo so c8 processes their coverage
// (it ignores scripts outside cwd before source-map remap). Otherwise /tmp.
const bundleParent = process.env.NODE_V8_COVERAGE
? fs.mkdirSync(path.join(repoRoot, '.test-bundles'), { recursive: true }) || path.join(repoRoot, '.test-bundles')
: os.tmpdir();
const tempDir = fs.mkdtempSync(path.join(bundleParent, 'parallel-reader-tests-'));
async function requireBundledModule(relativePath) { async function requireBundledModule(relativePath) {
const entry = path.join(repoRoot, relativePath); const entry = path.join(repoRoot, relativePath);
@ -16,23 +26,31 @@ async function requireBundledModule(relativePath) {
platform: 'node', platform: 'node',
format: 'cjs', format: 'cjs',
outfile, outfile,
sourcemap: 'inline',
sourcesContent: true,
sourceRoot: repoRoot,
plugins: [ plugins: [
{ {
name: 'obsidian-stub', name: 'obsidian-stub',
setup(build) { setup(build) {
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: 'obsidian-stub', namespace: 'stub' })); build.onResolve({ filter: /^obsidian$/ }, () => ({ path: 'obsidian-stub', namespace: 'stub' }));
build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({
contents: 'module.exports = { requestUrl: async () => { throw new Error("requestUrl not available in direct module tests"); } };', contents:
'module.exports = { requestUrl: async () => { throw new Error("requestUrl not available in direct module tests"); } };',
loader: 'js', loader: 'js',
})); }));
}, },
}, },
], ],
}); });
require('./coverage-sourcemap').fixInlineSourceMap(outfile, repoRoot);
return require(outfile); return require(outfile);
} }
function cleanup() { function cleanup() {
// Skipped under c8 coverage: c8 reads V8 coverage at exit and needs the
// bundled file (with its inline source map) still on disk to remap to src/.
if (process.env.NODE_V8_COVERAGE) return;
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }

View file

@ -1,49 +1,16 @@
const assert = require('assert'); const { assert, t } = require('./test-setup');
const Module = require('module');
const originalLoad = Module._load; const { GenerationJobAlreadyRunningError, GenerationJobCancelledError, GenerationJobManager, classifyGenerationError } =
Module._load = function load(request, parent, isMain) { t;
if (request === 'obsidian') {
class Plugin {}
class ItemView {}
class PluginSettingTab {}
class Setting {}
class Notice {}
class MarkdownView {}
class TFile {}
class Menu {}
class Modal {}
return {
Plugin,
ItemView,
PluginSettingTab,
Setting,
Notice,
MarkdownView,
TFile,
Menu,
Modal,
MarkdownRenderer: { render: async () => {} },
requestUrl: async () => ({ status: 200, json: {}, text: '{}' }),
setIcon: () => {},
};
}
return originalLoad.call(this, request, parent, isMain);
};
const {
GenerationJobAlreadyRunningError,
GenerationJobCancelledError,
GenerationJobManager,
classifyGenerationError,
} = require('../main.js').__test;
async function testSingleFlightAndCleanup() { async function testSingleFlightAndCleanup() {
const manager = new GenerationJobManager(); const manager = new GenerationJobManager();
let release; let release;
const blocker = new Promise(resolve => { release = resolve; }); const blocker = new Promise((resolve) => {
release = resolve;
});
const first = manager.start('note.md', async job => { const first = manager.start('note.md', async (job) => {
job.setPhase('reading'); job.setPhase('reading');
await blocker; await blocker;
job.setPhase('done'); job.setPhase('done');
@ -52,10 +19,7 @@ async function testSingleFlightAndCleanup() {
assert.strictEqual(manager.isRunning('note.md'), true); assert.strictEqual(manager.isRunning('note.md'), true);
assert.strictEqual(manager.get('note.md').phase, 'reading'); assert.strictEqual(manager.get('note.md').phase, 'reading');
await assert.rejects( await assert.rejects(() => manager.start('note.md', async () => 'duplicate'), GenerationJobAlreadyRunningError);
() => manager.start('note.md', async () => 'duplicate'),
GenerationJobAlreadyRunningError
);
release(); release();
assert.strictEqual(await first, 'ok'); assert.strictEqual(await first, 'ok');
@ -63,12 +27,185 @@ async function testSingleFlightAndCleanup() {
assert.strictEqual(manager.get('note.md'), null); assert.strictEqual(manager.get('note.md'), null);
} }
async function testGlobalConcurrencyLimit() {
const manager = new GenerationJobManager(2); // max 2 concurrent
const releases = [null, null, null, null];
const blockers = releases.map(
(_, i) =>
new Promise((r) => {
releases[i] = r;
}),
);
const startedAt = [];
const promises = ['a.md', 'b.md', 'c.md', 'd.md'].map((path, i) =>
manager.start(path, async () => {
startedAt.push(i);
await blockers[i];
return path;
}),
);
// Let microtasks flush
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.isRunning('a.md'), true, 'a is running');
assert.strictEqual(manager.isRunning('b.md'), true, 'b is running');
assert.strictEqual(manager.isRunning('c.md'), false, 'c is queued, not running yet');
assert.strictEqual(manager.isRunning('d.md'), false, 'd is queued');
assert.strictEqual(manager.isPending('c.md'), true, 'c is pending (queued)');
assert.strictEqual(manager.waitingCount(), 2, 'two waiters queued');
assert.deepStrictEqual(startedAt, [0, 1], 'only first 2 entered runner');
// Release first -> c gets slot
releases[0]();
await promises[0];
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.isRunning('c.md'), true, 'c promoted after a finishes');
assert.strictEqual(manager.waitingCount(), 1, 'one waiter left');
// Same-key dedup while queued: starting c again throws (without waiting)
await assert.rejects(
() => manager.start('d.md', async () => 'dup'),
GenerationJobAlreadyRunningError,
'same-key (queued) dedup throws',
);
releases[1]();
releases[2]();
releases[3]();
await Promise.all(promises);
assert.strictEqual(manager.waitingCount(), 0);
assert.strictEqual(manager.isRunning('d.md'), false, 'all jobs settled');
}
async function testNoOverbookingDuringRelease() {
// Regression for the microtask race: when a queued waiter is resolved by
// releaseSlot(), a synchronous start() call must NOT slip past with the now-stale
// jobs.size and bring concurrent runners above maxConcurrent.
const manager = new GenerationJobManager(2);
const releases = [null, null, null];
const runnerEntered = [];
const blockers = releases.map(
(_, i) =>
new Promise((r) => {
releases[i] = r;
}),
);
const a = manager.start('a.md', async () => {
runnerEntered.push('a');
await blockers[0];
return 'a';
});
const b = manager.start('b.md', async () => {
runnerEntered.push('b');
await blockers[1];
return 'b';
});
// c is queued (slot full); c will block too so we can observe state during transitions.
const c = manager.start('c.md', async () => {
runnerEntered.push('c');
await blockers[2];
return 'c';
});
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.waitingCount(), 1, 'c is queued');
assert.deepStrictEqual(runnerEntered, ['a', 'b'], 'only 2 runners entered');
// Release a → c's waiter resolves and continuation queued. c will run and block.
releases[0]();
await a;
await new Promise((r) => setTimeout(r, 5));
// After a settles + microtasks: c is now in jobs (b + c running), runnerEntered = a,b,c
assert.deepStrictEqual(runnerEntered, ['a', 'b', 'c'], 'c promoted after a finished');
// Now slots are full again (b + c). New start('d.md') MUST queue, not jump in.
const d = manager.start('d.md', async () => {
runnerEntered.push('d');
return 'd';
});
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.waitingCount(), 1, 'd queued because b+c are running');
assert.deepStrictEqual(runnerEntered, ['a', 'b', 'c'], 'd not entered while b+c running');
releases[1]();
releases[2]();
await Promise.all([b, c, d]);
assert.ok(runnerEntered.includes('d'), 'd entered eventually');
assert.strictEqual(manager.waitingCount(), 0);
}
async function testCancelAllWaiters() {
const manager = new GenerationJobManager(1);
let release;
const blocker = new Promise((r) => {
release = r;
});
const running = manager.start('first.md', async () => {
await blocker;
return 'first';
});
// queued: should be cancellable
const queued1 = manager.start('q1.md', async () => 'q1');
const queued2 = manager.start('q2.md', async () => 'q2');
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.waitingCount(), 2, 'two queued');
const drained = manager.cancelAllWaiters();
assert.strictEqual(drained, 2, 'cancelAllWaiters returns drained count');
await assert.rejects(queued1, GenerationJobCancelledError, 'q1 rejects with cancelled');
await assert.rejects(queued2, GenerationJobCancelledError, 'q2 rejects with cancelled');
release();
assert.strictEqual(await running, 'first', 'running job still completes');
assert.strictEqual(manager.waitingCount(), 0);
}
async function testCancelAll() {
const manager = new GenerationJobManager(1);
let release;
const blocker = new Promise((r) => {
release = r;
});
let runningCancelHook = false;
const running = manager.start('run.md', async (job) => {
job.onCancel(() => {
runningCancelHook = true;
});
await blocker;
job.throwIfCancelled();
return 'run';
});
const queued = manager.start('q.md', async () => 'q');
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.isRunning('run.md'), true, 'one running');
assert.strictEqual(manager.waitingCount(), 1, 'one queued');
const cancelled = manager.cancelAll();
assert.strictEqual(cancelled, 2, 'cancelAll cancels the running job and drains the queued waiter');
assert.strictEqual(runningCancelHook, true, 'running job cancel handler fired (aborts in-flight HTTP/CLI)');
release();
await assert.rejects(running, GenerationJobCancelledError, 'cancelled running job rejects');
await assert.rejects(queued, GenerationJobCancelledError, 'queued job rejects');
assert.strictEqual(manager.waitingCount(), 0);
}
async function testCancelSignalsRunnerAndCleansUp() { async function testCancelSignalsRunnerAndCleansUp() {
const manager = new GenerationJobManager(); const manager = new GenerationJobManager();
let cancelHookCalled = false; let cancelHookCalled = false;
const running = manager.start('cancel.md', async job => { const running = manager.start('cancel.md', async (job) => {
job.onCancel(() => { cancelHookCalled = true; }); job.onCancel(() => {
cancelHookCalled = true;
});
await Promise.resolve(); await Promise.resolve();
job.throwIfCancelled(); job.throwIfCancelled();
return 'should-not-complete'; return 'should-not-complete';
@ -85,6 +222,12 @@ function testErrorClassification() {
assert.strictEqual(classifyGenerationError(new Error('API key 未设置')), 'auth'); assert.strictEqual(classifyGenerationError(new Error('API key 未设置')), 'auth');
assert.strictEqual(classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout'); assert.strictEqual(classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
assert.strictEqual(classifyGenerationError(new Error('OpenAI API 429: rate limit')), 'rate-limit'); assert.strictEqual(classifyGenerationError(new Error('OpenAI API 429: rate limit')), 'rate-limit');
assert.strictEqual(
classifyGenerationError(new Error('request to https://api failed, reason: ECONNREFUSED')),
'network',
);
assert.strictEqual(classifyGenerationError(new Error('Failed to fetch')), 'network');
assert.strictEqual(classifyGenerationError(new Error('getaddrinfo ENOTFOUND api.openai.com')), 'network');
assert.strictEqual(classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema'); assert.strictEqual(classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
assert.strictEqual(classifyGenerationError(new Error('Model 未设置')), 'config'); assert.strictEqual(classifyGenerationError(new Error('Model 未设置')), 'config');
assert.strictEqual(classifyGenerationError(new Error('something else')), 'unknown'); assert.strictEqual(classifyGenerationError(new Error('something else')), 'unknown');
@ -92,6 +235,10 @@ function testErrorClassification() {
(async () => { (async () => {
await testSingleFlightAndCleanup(); await testSingleFlightAndCleanup();
await testGlobalConcurrencyLimit();
await testNoOverbookingDuringRelease();
await testCancelAllWaiters();
await testCancelAll();
await testCancelSignalsRunnerAndCleansUp(); await testCancelSignalsRunnerAndCleansUp();
testErrorClassification(); testErrorClassification();
console.log('generation job manager tests passed'); console.log('generation job manager tests passed');

View file

@ -2,32 +2,47 @@ const { assert, t } = require('./test-setup');
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation'); assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'appTitle'), 'Parallel Reader', 'en translation'); assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'appTitle'), 'Parallel Reader', 'en translation');
assert.strictEqual(t.translate({ uiLanguage: 'ja' }, 'settingTestBackendButton'), 'テスト');
assert.strictEqual(t.translate({ uiLanguage: 'ko' }, 'settingTestBackendButton'), '테스트');
assert.strictEqual(t.translate({ uiLanguage: 'fr' }, 'settingTestBackendButton'), 'Tester');
assert.strictEqual(t.translate({ uiLanguage: 'de' }, 'settingTestBackendButton'), 'Testen');
assert.strictEqual(t.translate({ uiLanguage: 'es' }, 'settingTestBackendButton'), 'Probar');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'nonexistent_key'), 'nonexistent_key', 'missing key returns key'); assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'nonexistent_key'), 'nonexistent_key', 'missing key returns key');
assert.strictEqual( assert.strictEqual(
t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 5 }), t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 5 }),
'Cleared 5 cache entries', 'Cleared 5 cache entries',
'variable interpolation' 'variable interpolation',
);
assert.strictEqual(
t.translate({ uiLanguage: 'fr' }, 'cacheClearedAll', { count: 5 }),
'5 entrées de cache effacées',
'new locale interpolation',
); );
assert.strictEqual( assert.strictEqual(
t.translate({ uiLanguage: 'en' }, 'exported', { path: 'foo/bar.md' }), t.translate({ uiLanguage: 'en' }, 'exported', { path: 'foo/bar.md' }),
'Exported → foo/bar.md', 'Exported → foo/bar.md',
'path variable' 'path variable',
); );
assert.strictEqual( assert.strictEqual(
t.translate(null, 'appTitle'), t.translate(null, 'appTitle'),
'Parallel Reader', 'Parallel Reader',
'null settings defaults to en in Node.js (no navigator)' 'null settings defaults to en in Node.js (no navigator)',
); );
assert.strictEqual( assert.strictEqual(
t.translate({ uiLanguage: 'en' }, 'generationDone', { count: 3, suffix: '' }), t.translate({ uiLanguage: 'en' }, 'generationDone', { count: 3, suffix: '' }),
'Generated 3 sections', 'Generated 3 sections',
'variable interpolation with multiple vars', 'variable interpolation with multiple vars',
); );
assert.strictEqual( assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation for appTitle');
t.translate({ uiLanguage: 'zh' }, 'appTitle'),
'对照阅读笔记',
'zh translation for appTitle',
);
assert.strictEqual(t.translate({ uiLanguage: 'en' }, '__no_such_key__'), '__no_such_key__', 'missing key returns key'); assert.strictEqual(t.translate({ uiLanguage: 'en' }, '__no_such_key__'), '__no_such_key__', 'missing key returns key');
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'settingTestBackendButton'), '测试');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'confirmRegenerateProceed'), 'Regenerate');
assert.strictEqual(
t.translate({ uiLanguage: 'en' }, 'confirmExportOverwrite', { path: 'Reading/A.md' }),
'Export file already exists: Reading/A.md\nOverwrite it?',
);
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'batchFolderConfirm'), '确定');
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'batchFolderCancel'), '取消');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'batchFolderCancel'), 'Cancel');
console.log('i18n tests passed'); console.log('i18n tests passed');

View file

@ -1,521 +0,0 @@
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const Module = require('module');
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === 'obsidian') {
class Plugin {}
class ItemView {
constructor(leaf) {
this.leaf = leaf;
this.containerEl = { children: [{}, {}] };
}
}
class PluginSettingTab {}
class Setting {}
class Notice {}
class MarkdownView {}
class TFile {}
class Menu {}
class Modal {}
return {
Plugin,
ItemView,
PluginSettingTab,
Setting,
Notice,
MarkdownView,
TFile,
Menu,
Modal,
MarkdownRenderer: { render: async () => {} },
requestUrl: async () => ({ status: 200, json: {}, text: '{}' }),
setIcon: () => {},
};
}
return originalLoad.call(this, request, parent, isMain);
};
const plugin = require('../main.js');
const t = plugin.__test;
assert.ok(t, 'test helpers should be exported');
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8');
const viewSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'view.ts'), 'utf8');
assert.ok(!/\basync\s+onOpen\s*\(/.test(viewSource), 'ParallelReaderView.onOpen should not be async without await');
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(viewSource), 'empty onClose should not be async');
assert.ok(/focusSummaryPane\s*\(\)/.test(viewSource), 'summary pane should expose a focus helper');
assert.ok(/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(viewSource), 'summary pane focus should not scroll the page');
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(viewSource), 'card navigation should focus the summary pane');
assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path');
assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable');
assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches');
assert.ok(/cacheTouch[\s\S]*scheduleCacheSave/.test(mainSource), 'cacheTouch should schedule a cache save');
assert.ok(!/cacheTouch[\s\S]{0,220}await this\.saveCache/.test(mainSource), 'cacheTouch should not synchronously write cache.json');
assert.ok(/handleFileRename[\s\S]*cacheManager\.move/.test(mainSource), 'file rename should delegate cache moves');
assert.ok(!/handleFileRename[\s\S]*cacheManager\.cache\[/.test(mainSource), 'file rename should not mutate cache directly');
assert.ok(!/function addIconButton/.test(mainSource), 'UI icon helper should live outside main.ts');
assert.ok(!/function addTextButton/.test(mainSource), 'UI text-button helper should live outside main.ts');
assert.ok(!/function copyToClipboard/.test(mainSource), 'clipboard helper should live outside main.ts');
assert.strictEqual(typeof t.cardsToMarkdown, 'function');
assert.strictEqual(typeof t.cancellationNoticeKey, 'function');
assert.strictEqual(typeof t.summarizeDocument, 'function');
assert.strictEqual(typeof t.addIconButton, 'function');
assert.strictEqual(typeof t.addTextButton, 'function');
assert.strictEqual(typeof t.copyToClipboard, 'function');
assert.strictEqual(typeof t.resolveCliPath, 'function');
assert.strictEqual(typeof t.runCli, 'function');
assert.strictEqual(typeof t.buildPrompts, 'function');
assert.strictEqual(typeof t.buildOpenAiChatBody, 'function');
assert.strictEqual(typeof t.extractJson, 'function');
assert.strictEqual(typeof t.findLineForAnchor, 'function');
assert.strictEqual(typeof t.folderPathsForTarget, 'function');
assert.strictEqual(typeof t.getApiBaseUrl, 'function');
assert.strictEqual(typeof t.generationFingerprint, 'function');
assert.strictEqual(typeof t.hasUnsafeBatchFolderSegments, 'function');
assert.strictEqual(typeof t.CacheManager, 'function');
assert.strictEqual(typeof t.GenerationJobManager, 'function');
assert.strictEqual(typeof t.createBatchRunState, 'function');
assert.strictEqual(typeof t.modelForApi, 'function');
assert.strictEqual(typeof t.activeSectionLine, 'function');
assert.strictEqual(typeof t.touchCacheEntry, 'function');
assert.strictEqual(typeof t.nextCardIndex, 'function');
assert.strictEqual(typeof t.pruneCacheEntries, 'function');
assert.strictEqual(typeof t.removeCardAt, 'function');
assert.strictEqual(typeof t.activeIndexAfterCardDelete, 'function');
assert.strictEqual(typeof t.createRafThrottledHandler, 'function');
assert.strictEqual(typeof t.visibleTopProbeY, 'function');
assert.strictEqual(typeof t.serializeCacheFile, 'function');
assert.strictEqual(typeof t.shouldConfirmRegenerate, 'function');
assert.strictEqual(typeof t.translate, 'function');
assert.strictEqual(typeof t.updateCardAt, 'function');
assert.strictEqual(typeof t.validateBatchFolderInput, 'function');
assert.strictEqual(typeof t.normalizeSettings, 'function');
assert.strictEqual(typeof t.normalizeStreamingTimeoutMs, 'function');
const baseSettings = {
backend: 'api',
apiProvider: 'openai',
apiFormat: 'openai-chat',
apiBaseUrl: 'https://api.openai.com/v1',
apiAuthType: 'bearer',
apiKey: 'test-key',
apiMaxTokens: 123,
model: 'openai/gpt-5.1',
};
assert.notStrictEqual(
t.generationFingerprint(baseSettings),
t.generationFingerprint({ ...baseSettings, model: 'openai/gpt-5.2' }),
'cache fingerprint should change when model changes'
);
assert.notStrictEqual(
t.generationFingerprint(baseSettings),
t.generationFingerprint({ ...baseSettings, maxDocChars: 50000 }),
'cache fingerprint should change when input truncation limit changes'
);
assert.notStrictEqual(
t.generationFingerprint(baseSettings),
t.generationFingerprint({ ...baseSettings, promptLanguage: 'en' }),
'cache fingerprint should change when prompt language changes'
);
assert.notStrictEqual(
t.generationFingerprint(baseSettings),
t.generationFingerprint({ ...baseSettings, minCards: 3, maxCards: 8 }),
'cache fingerprint should change when card count range changes'
);
assert.notStrictEqual(
t.generationFingerprint(baseSettings),
t.generationFingerprint({ ...baseSettings, customSystemPrompt: 'custom prompt' }),
'cache fingerprint should change when custom prompt changes'
);
assert.strictEqual(
t.generationFingerprint(baseSettings),
t.generationFingerprint({ ...baseSettings, uiLanguage: 'en' }),
'cache fingerprint should not change when UI language changes'
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
'cancelRequestedApiInFlight',
'API cancellation should explain that the in-flight network request cannot be aborted'
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }),
'cancelRequested',
'CLI cancellation can use the generic cancellation notice'
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }),
'cancelRequested',
'API cancellation outside the request phase can use the generic notice'
);
assert.strictEqual(t.resolveCliPath('codex', ' /tmp/codex '), '/tmp/codex');
assert.throws(
() => t.modelForApi({ ...baseSettings, model: '', uiLanguage: 'en' }),
/Model is not set/,
'settings errors should respect English UI mode'
);
assert.throws(
() => t.getApiBaseUrl({
...baseSettings,
apiProvider: 'custom-openai-compatible',
apiBaseUrl: '',
uiLanguage: 'en',
}),
/Custom provider requires an API Base URL/,
'custom provider base URL errors should respect English UI mode'
);
const contentHash = crypto.createHash('sha1').update('hello', 'utf8').digest('hex');
assert.strictEqual(
t.cacheEntryMatches({
schemaVersion: t.CACHE_SCHEMA_VERSION,
contentHash,
settingsHash: t.generationFingerprint(baseSettings),
}, 'hello', baseSettings),
true,
'cache should match content, schema version, and generation fingerprint'
);
assert.strictEqual(
t.cacheEntryMatches({
schemaVersion: t.CACHE_SCHEMA_VERSION,
contentHash,
settingsHash: t.generationFingerprint({ ...baseSettings, model: 'openai/gpt-5.2' }),
}, 'hello', baseSettings),
false,
'cache should miss when generation settings change'
);
assert.strictEqual(
t.cacheEntryMatches({
schemaVersion: t.CACHE_SCHEMA_VERSION - 1,
contentHash,
settingsHash: t.generationFingerprint(baseSettings),
}, 'hello', baseSettings),
false,
'cache should miss when schema version changes'
);
const cacheForPrune = {
'old.md': { generatedAt: '2024-01-01T00:00:00.000Z' },
'new.md': { generatedAt: '2024-01-03T00:00:00.000Z' },
'touched.md': {
generatedAt: '2024-01-02T00:00:00.000Z',
lastAccessedAt: '2024-01-04T00:00:00.000Z',
},
};
assert.deepStrictEqual(t.pruneCacheEntries(cacheForPrune, 2), ['old.md']);
assert.deepStrictEqual(Object.keys(cacheForPrune).sort(), ['new.md', 'touched.md']);
assert.strictEqual(
t.findLineForAnchor('intro\nAlpha beta\nGamma\tDelta\nlast', 'Alpha beta Gamma Delta'),
1,
'whitespace-normalized anchor fallback should map back to the original source line'
);
const englishPrompt = t.buildPrompts('Hello world', {
...baseSettings,
promptLanguage: 'en',
minCards: 2,
maxCards: 4,
});
assert.ok(englishPrompt.system.includes('2-4'));
assert.ok(englishPrompt.system.includes('Write title, gist, and bullets in English.'));
assert.ok(englishPrompt.system.includes('You are a long-form reading summary assistant.'));
assert.ok(!englishPrompt.system.includes('你是一个长文阅读摘要助手'));
assert.ok(englishPrompt.user.startsWith('Source document:'));
const customPrompt = t.buildPrompts('Hello world', {
...baseSettings,
promptLanguage: 'auto',
minCards: 1,
maxCards: 2,
customSystemPrompt: 'Make {minCards}-{maxCards} cards. {languageInstruction}',
});
assert.ok(customPrompt.system.includes('Make 1-2 cards.'));
assert.ok(customPrompt.system.includes('不可覆盖的输出契约'));
assert.ok(customPrompt.system.includes('JSON shape'));
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdOpenView'), 'Open Parallel Reader pane');
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'cmdOpenView'), '打开对照笔记面板');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 3 }), 'Cleared 3 cache entries');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdCardNext'), 'Focus next summary card');
assert.strictEqual(t.nextCardIndex(-1, 3, 1), 0);
assert.strictEqual(t.nextCardIndex(-1, 3, -1), 2);
assert.strictEqual(t.nextCardIndex(0, 3, 1), 1);
assert.strictEqual(t.nextCardIndex(2, 3, 1), 2);
assert.strictEqual(t.nextCardIndex(0, 3, -1), 0);
assert.strictEqual(t.nextCardIndex(0, 0, 1), -1);
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 0), 3);
assert.strictEqual(t.activeSectionLine([{ startLine: -1 }], 0), -1);
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 1), -1);
const scheduledFrames = [];
let throttledCalls = 0;
const throttled = t.createRafThrottledHandler(() => { throttledCalls += 1; }, cb => {
scheduledFrames.push(cb);
return scheduledFrames.length;
});
throttled();
throttled();
throttled();
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be scheduled at most once per frame');
assert.strictEqual(throttledCalls, 0, 'throttled handler should not run synchronously');
scheduledFrames.shift()(0);
assert.strictEqual(throttledCalls, 1);
throttled();
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be schedulable after the frame runs');
assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 300 }), 130);
assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 1200 }), 180);
const cardList = [{ title: 'A' }, { title: 'B' }, { title: 'C' }];
assert.deepStrictEqual(t.removeCardAt(cardList, 1), [{ title: 'A' }, { title: 'C' }]);
assert.deepStrictEqual(t.removeCardAt(cardList, -1), cardList);
assert.deepStrictEqual(t.removeCardAt(cardList, 3), cardList);
assert.notStrictEqual(t.removeCardAt(cardList, 3), cardList);
assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 1), 1, 'deleting active card should select the next card');
assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 2), 1, 'deleting the last active card should select the previous card');
assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 2), 1, 'deleting before active card should shift active index left');
assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 0), 0, 'deleting after active card should keep active index');
assert.deepStrictEqual(
t.updateCardAt(cardList, 1, { title: 'B2', gist: 'G', bullets: ['x'] }),
[{ title: 'A' }, { title: 'B2', gist: 'G', bullets: ['x'] }, { title: 'C' }]
);
assert.deepStrictEqual(t.updateCardAt(cardList, -1, { title: 'X' }), cardList);
assert.deepStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
assert.notStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
assert.deepStrictEqual(t.folderPathsForTarget('Reading/Articles'), ['Reading', 'Reading/Articles']);
assert.deepStrictEqual(t.folderPathsForTarget('/Reading//Articles/'), ['Reading', 'Reading/Articles']);
assert.deepStrictEqual(t.folderPathsForTarget('Reading'), ['Reading']);
assert.deepStrictEqual(t.folderPathsForTarget(''), []);
const untouchedCacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' };
assert.strictEqual(t.touchCacheEntry(null), null);
const touchedEntry = t.touchCacheEntry(untouchedCacheEntry, '2024-01-05T00:00:00.000Z');
assert.notStrictEqual(touchedEntry, untouchedCacheEntry, 'touchCacheEntry returns new object');
assert.strictEqual(touchedEntry.lastAccessedAt, '2024-01-05T00:00:00.000Z', 'touched entry has updated timestamp');
assert.strictEqual(untouchedCacheEntry.lastAccessedAt, undefined, 'original entry not mutated');
assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, true), true);
assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, false), false);
assert.strictEqual(t.shouldConfirmRegenerate({ generatedAt: '2024-01-01T00:00:00.000Z' }, true), false);
assert.strictEqual(t.shouldConfirmRegenerate(null, true), false);
const serializedCache = t.serializeCacheFile({
'note.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 'A' }] },
});
assert.strictEqual(serializedCache.includes('\n'), false, 'cache.json should be compact');
assert.deepStrictEqual(JSON.parse(serializedCache).entries['note.md'].cards, [{ title: 'A' }]);
const noisyJson = '说明文字 {"cards":[{"title":"A","anchor":"保留 { 花括号 } 字符","gist":"G","bullets":["B"]}]} trailing';
const extracted = t.extractJson(noisyJson);
assert.deepStrictEqual(JSON.parse(extracted).cards[0].bullets, ['B']);
const openAiChatBody = t.buildOpenAiChatBody('system JSON', 'user', baseSettings);
assert.strictEqual(openAiChatBody.max_completion_tokens, 123);
assert.strictEqual(openAiChatBody.max_tokens, undefined);
assert.strictEqual(openAiChatBody.response_format.type, 'json_schema');
const compatChatBody = t.buildOpenAiChatBody('system JSON', 'user', {
...baseSettings,
apiProvider: 'openrouter',
model: 'openrouter/anthropic/claude-sonnet-4-5',
});
assert.strictEqual(compatChatBody.max_tokens, 123);
assert.strictEqual(compatChatBody.max_completion_tokens, undefined);
assert.strictEqual(compatChatBody.response_format.type, 'json_schema');
const responsesBody = t.buildOpenAiResponsesBody('system JSON', 'user', baseSettings);
assert.strictEqual(responsesBody.text.format.type, 'json_schema');
const geminiBody = t.buildGeminiBody('system JSON', 'user', {
...baseSettings,
apiProvider: 'google',
apiFormat: 'google-generative-ai',
});
assert.strictEqual(geminiBody.generationConfig.responseMimeType, 'application/json');
assert.strictEqual(geminiBody.generationConfig.responseJsonSchema.type, 'object');
const anthropicBody = t.buildAnthropicMessagesBody('system JSON', 'user', {
...baseSettings,
apiProvider: 'anthropic',
apiFormat: 'anthropic-messages',
model: 'anthropic/claude-sonnet-4-6',
});
assert.strictEqual(anthropicBody.tools[0].name, 'record_parallel_reader_cards');
assert.strictEqual(anthropicBody.tool_choice.name, 'record_parallel_reader_cards');
assert.strictEqual(
t.buildAnthropicMessagesBody('system JSON', 'user', baseSettings, { structured: false }).tools,
undefined
);
const markdown = t.cardsToMarkdown('Example', [{
title: '第一段',
anchor: '原文引用',
gist: '核心摘要',
bullets: ['要点 A', '要点 B'],
}]);
assert.ok(markdown.includes('# Example'));
assert.ok(markdown.includes('## 第一段'));
assert.ok(markdown.includes('- 要点 A'));
async function testOpenAiStructuredFallback() {
const calls = [];
const requestUrlImpl = async req => {
const body = JSON.parse(req.body);
calls.push(body);
if (calls.length === 1) {
return { status: 400, text: 'unsupported response_format json_schema' };
}
return {
status: 200,
json: {
choices: [{
message: {
content: '{"cards":[{"title":"T","anchor":"A","gist":"G","bullets":["B"]}]}',
},
}],
},
};
};
const originalWarn = console.warn;
console.warn = () => {};
let cards;
try {
cards = await t.summarizeViaApi(requestUrlImpl, 'system JSON', 'user', baseSettings);
} finally {
console.warn = originalWarn;
}
assert.strictEqual(calls.length, 2);
assert.strictEqual(calls[0].response_format.type, 'json_schema');
assert.strictEqual(calls[1].response_format, undefined);
assert.deepStrictEqual(cards, [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }]);
}
async function testProviderMissingApiKeyUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => {
throw new Error('request should not be sent');
}, 'system JSON', 'user', {
...baseSettings,
apiKey: '',
apiKeyEnvVar: '',
uiLanguage: 'en',
}),
/API key is not set/
);
}
async function testProviderHeaderJsonErrorUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => {
throw new Error('request should not be sent');
}, 'system JSON', 'user', {
...baseSettings,
apiHeaders: '{"x":',
uiLanguage: 'en',
}),
/Custom headers JSON parse failed/
);
}
async function testProviderResponseNonJsonUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => ({
status: 200,
text: '<html>not json</html>',
}), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat returned non-JSON/
);
}
async function testProviderRequestFailureUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => {
throw new Error('network down');
}, 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat request failed: network down/
);
}
async function testProviderApiStatusErrorUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => ({
status: 500,
text: 'upstream exploded',
}), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat API returned HTTP 500: upstream exploded/
);
}
async function testSchemaNonJsonErrorUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => ({
status: 200,
json: {
choices: [{
message: {
content: 'not json',
},
}],
},
}), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/LLM returned non-JSON/
);
}
async function testAnthropicToolUseParsing() {
const requestUrlImpl = async req => {
const body = JSON.parse(req.body);
assert.strictEqual(body.tools[0].name, 'record_parallel_reader_cards');
assert.strictEqual(body.tool_choice.name, 'record_parallel_reader_cards');
return {
status: 200,
json: {
content: [{
type: 'tool_use',
name: 'record_parallel_reader_cards',
input: {
cards: [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }],
},
}],
},
};
};
const cards = await t.summarizeViaApi(requestUrlImpl, 'system JSON', 'user', {
...baseSettings,
apiProvider: 'anthropic',
apiFormat: 'anthropic-messages',
apiBaseUrl: 'https://api.anthropic.com/v1',
apiAuthType: 'x-api-key',
model: 'anthropic/claude-sonnet-4-6',
});
assert.deepStrictEqual(cards, [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }]);
}
(async () => {
await testOpenAiStructuredFallback();
await testProviderMissingApiKeyUsesEnglishUi();
await testProviderHeaderJsonErrorUsesEnglishUi();
await testProviderResponseNonJsonUsesEnglishUi();
await testProviderRequestFailureUsesEnglishUi();
await testProviderApiStatusErrorUsesEnglishUi();
await testSchemaNonJsonErrorUsesEnglishUi();
await testAnthropicToolUseParsing();
console.log('tests passed');
})().catch(e => {
console.error(e);
process.exit(1);
});

52
tests/obsidian-mock.js Normal file
View file

@ -0,0 +1,52 @@
/**
* Shared obsidian module mock for all test files.
* Must be require()'d before any module that imports 'obsidian'.
*/
const Module = require('module');
const originalLoad = Module._load;
let requestUrlMock = async () => ({ status: 200, json: {}, text: '{}' });
Module._load = function load(request, parent, isMain) {
if (request === 'obsidian') {
class Plugin {}
class ItemView {
constructor(leaf) {
this.leaf = leaf;
this.containerEl = { children: [{}, {}] };
}
}
class PluginSettingTab {}
class Setting {}
class Notice {}
class MarkdownView {}
class TFile {}
class Menu {}
class Modal {}
return {
Plugin,
ItemView,
PluginSettingTab,
Setting,
Notice,
MarkdownView,
TFile,
Menu,
Modal,
MarkdownRenderer: { render: async () => {} },
requestUrl: (params) => requestUrlMock(params),
setIcon: () => {},
};
}
return originalLoad.call(this, request, parent, isMain);
};
module.exports = {
getRequestUrlMock() {
return requestUrlMock;
},
setRequestUrlMock(fn) {
requestUrlMock = fn;
},
};

View file

@ -0,0 +1,36 @@
const { assert, t } = require('./test-setup');
const plugin = new t.ParallelReaderPlugin();
const failedFile = { path: 'failed.md' };
const originalConsoleError = console.error;
plugin.cacheManager = { get: () => null };
plugin.jobs = {
isRunning: () => false,
isPending: () => false,
start: async () => {
throw new Error('backend down');
},
cancelAllWaiters: () => 0,
};
plugin.t = (key) => key;
(async () => {
console.error = () => {};
try {
await assert.doesNotReject(
() => plugin.runForFile(failedFile, false),
'single-file UI path should keep handling errors internally',
);
await assert.rejects(
() => plugin.runForFile(failedFile, false, { rethrowErrors: true }),
/backend down/,
'batch path should be able to count failed generation attempts',
);
} finally {
console.error = originalConsoleError;
}
console.log('plugin batch tests passed');
})();

View file

@ -82,26 +82,26 @@ assert.strictEqual(gemBody.generationConfig.responseMimeType, 'application/json'
assert.strictEqual( assert.strictEqual(
t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openai' }), t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openai' }),
'max_completion_tokens', 'max_completion_tokens',
'OpenAI uses max_completion_tokens' 'OpenAI uses max_completion_tokens',
); );
assert.strictEqual( assert.strictEqual(
t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openrouter' }), t.tokenLimitFieldForOpenAiChat({ ...baseSettings, apiProvider: 'openrouter' }),
'max_tokens', 'max_tokens',
'OpenRouter uses max_tokens' 'OpenRouter uses max_tokens',
); );
// ── parseApiHeaders ── // ── parseApiHeaders ──
assert.deepStrictEqual( assert.deepStrictEqual(
t.parseApiHeaders('{"X-Custom": "value", "Authorization": "Bearer tok"}'), t.parseApiHeaders('{"X-Custom": "value", "Authorization": "Bearer tok"}'),
{ 'X-Custom': 'value', 'Authorization': 'Bearer tok' }, { 'X-Custom': 'value', Authorization: 'Bearer tok' },
'parseApiHeaders: JSON object input', 'parseApiHeaders: JSON object input',
); );
assert.deepStrictEqual(t.parseApiHeaders(''), {}, 'parseApiHeaders: empty string returns empty object'); assert.deepStrictEqual(t.parseApiHeaders(''), {}, 'parseApiHeaders: empty string returns empty object');
assert.deepStrictEqual(t.parseApiHeaders(' '), {}, 'parseApiHeaders: whitespace-only returns empty object'); assert.deepStrictEqual(t.parseApiHeaders(' '), {}, 'parseApiHeaders: whitespace-only returns empty object');
assert.deepStrictEqual( assert.deepStrictEqual(
t.parseApiHeaders('X-Custom: value\nAuthorization: Bearer tok'), t.parseApiHeaders('X-Custom: value\nAuthorization: Bearer tok'),
{ 'X-Custom': 'value', 'Authorization': 'Bearer tok' }, { 'X-Custom': 'value', Authorization: 'Bearer tok' },
'parseApiHeaders: line-based input', 'parseApiHeaders: line-based input',
); );
assert.deepStrictEqual( assert.deepStrictEqual(
@ -131,7 +131,7 @@ assert.deepStrictEqual(
); );
assert.deepStrictEqual( assert.deepStrictEqual(
t.parseApiHeaders('Authorization: Bearer abc:def:ghi'), t.parseApiHeaders('Authorization: Bearer abc:def:ghi'),
{ 'Authorization': 'Bearer abc:def:ghi' }, { Authorization: 'Bearer abc:def:ghi' },
'parseApiHeaders: value with colons preserved', 'parseApiHeaders: value with colons preserved',
); );
@ -157,8 +157,16 @@ async function testSummarizeDocumentAnchorSorting() {
setRequestUrlMock(prev); setRequestUrlMock(prev);
} }
assert.deepStrictEqual(sections.map((s) => s.title), ['First', 'Second'], 'sorts by anchor line'); assert.deepStrictEqual(
assert.deepStrictEqual(sections.map((s) => s.startLine), [1, 3], 'resolves anchor lines'); sections.map((s) => s.title),
['First', 'Second'],
'sorts by anchor line',
);
assert.deepStrictEqual(
sections.map((s) => s.startLine),
[1, 3],
'resolves anchor lines',
);
} }
async function testStructuredOutputFallback() { async function testStructuredOutputFallback() {
@ -173,9 +181,7 @@ async function testStructuredOutputFallback() {
text: JSON.stringify({ error: 'response_format is not supported' }), text: JSON.stringify({ error: 'response_format is not supported' }),
}; };
} }
return openAiCardsResponse([ return openAiCardsResponse([{ title: 'Fallback', anchor: 'fallback anchor', gist: 'G', bullets: ['B'] }]);
{ title: 'Fallback', anchor: 'fallback anchor', gist: 'G', bullets: ['B'] },
]);
}); });
try { try {
@ -201,11 +207,12 @@ async function testStructuredOutputNonRetryableError() {
try { try {
await assert.rejects( await assert.rejects(
() => t.summarizeDocument( () =>
'test content', t.summarizeDocument(
{ ...baseSettings, streaming: false, promptLanguage: 'en', minCards: 1, maxCards: 3, maxDocChars: 10000 }, 'test content',
{ phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} }, { ...baseSettings, streaming: false, promptLanguage: 'en', minCards: 1, maxCards: 3, maxDocChars: 10000 },
), { phase: 'queued', onCancel: () => {}, throwIfCancelled: () => {}, setPhase: () => {} },
),
/500/, /500/,
'non-retryable error (500) is not caught by structured fallback', 'non-retryable error (500) is not caught by structured fallback',
); );
@ -214,10 +221,132 @@ async function testStructuredOutputNonRetryableError() {
} }
} }
async function testProviderMissingApiKey() {
await assert.rejects(
() =>
t.summarizeViaApi(
async () => {
throw new Error('request should not be sent');
},
'system JSON',
'user',
{ ...baseSettings, apiKey: '', apiKeyEnvVar: '', uiLanguage: 'en' },
),
/API key is not set/,
);
}
async function testProviderHeaderJsonError() {
await assert.rejects(
() =>
t.summarizeViaApi(
async () => {
throw new Error('request should not be sent');
},
'system JSON',
'user',
{ ...baseSettings, apiHeaders: '{"x":', uiLanguage: 'en' },
),
/Custom headers JSON parse failed/,
);
}
async function testProviderResponseNonJson() {
await assert.rejects(
() =>
t.summarizeViaApi(async () => ({ status: 200, text: '<html>not json</html>' }), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat returned non-JSON/,
);
}
async function testProviderRequestFailure() {
await assert.rejects(
() =>
t.summarizeViaApi(
async () => {
throw new Error('network down');
},
'system JSON',
'user',
{ ...baseSettings, uiLanguage: 'en' },
),
/OpenAI-compatible Chat request failed: network down/,
);
}
async function testProviderApiStatusError() {
await assert.rejects(
() =>
t.summarizeViaApi(async () => ({ status: 500, text: 'upstream exploded' }), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat API returned HTTP 500: upstream exploded/,
);
}
async function testAnthropicToolUseParsing() {
const cards = await t.summarizeViaApi(
async (req) => {
const body = JSON.parse(req.body);
assert.strictEqual(body.tools[0].name, 'record_parallel_reader_cards');
return {
status: 200,
json: {
content: [
{
type: 'tool_use',
name: 'record_parallel_reader_cards',
input: { cards: [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }] },
},
],
},
};
},
'system JSON',
'user',
{
...baseSettings,
apiProvider: 'anthropic',
apiFormat: 'anthropic-messages',
apiBaseUrl: 'https://api.anthropic.com/v1',
apiAuthType: 'x-api-key',
model: 'anthropic/claude-sonnet-4-6',
},
);
assert.deepStrictEqual(cards, [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }]);
}
// cancellationNoticeKey
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
'cancelRequestedApiInFlight',
'API cancellation should explain that the in-flight network request cannot be aborted',
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }),
'cancelRequested',
'CLI cancellation can use the generic cancellation notice',
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }),
'cancelRequested',
'API cancellation outside the request phase can use the generic notice',
);
(async () => { (async () => {
await testSummarizeDocumentAnchorSorting(); await testSummarizeDocumentAnchorSorting();
await testStructuredOutputFallback(); await testStructuredOutputFallback();
await testStructuredOutputNonRetryableError(); await testStructuredOutputNonRetryableError();
await testProviderMissingApiKey();
await testProviderHeaderJsonError();
await testProviderResponseNonJson();
await testProviderRequestFailure();
await testProviderApiStatusError();
await testAnthropicToolUseParsing();
console.log('providers tests passed'); console.log('providers tests passed');
})().catch((e) => { })().catch((e) => {
console.error(e); console.error(e);

View file

@ -8,15 +8,18 @@ assert.strictEqual(extractJson(''), '', 'empty text returns empty');
assert.strictEqual(extractJson('{"a":1}'), '{"a":1}', 'already-valid JSON returned as-is'); assert.strictEqual(extractJson('{"a":1}'), '{"a":1}', 'already-valid JSON returned as-is');
assert.strictEqual( assert.strictEqual(
JSON.parse(extractJson('prefix {"cards":[]} suffix')).cards.length, JSON.parse(extractJson('prefix {"cards":[]} suffix')).cards.length,
0, 'extracts JSON from surrounding text' 0,
'extracts JSON from surrounding text',
); );
assert.strictEqual( assert.strictEqual(
JSON.parse(extractJson('```json\n{"cards":[]}\n```')).cards.length, JSON.parse(extractJson('```json\n{"cards":[]}\n```')).cards.length,
0, 'extracts JSON from fenced code block' 0,
'extracts JSON from fenced code block',
); );
assert.strictEqual( assert.strictEqual(
JSON.parse(extractJson('text {"a":1} and {"cards":[{"title":"T"}]} end')).cards[0].title, JSON.parse(extractJson('text {"a":1} and {"cards":[{"title":"T"}]} end')).cards[0].title,
'T', 'picks the largest valid JSON object' 'T',
'picks the largest valid JSON object',
); );
assert.strictEqual(extractJson('not json at all'), 'not json at all', 'non-JSON text returned as-is'); assert.strictEqual(extractJson('not json at all'), 'not json at all', 'non-JSON text returned as-is');
@ -29,14 +32,16 @@ assert.strictEqual(repairTruncatedCardsJson('not json'), null, 'non-cards text r
assert.strictEqual(repairTruncatedCardsJson(''), null, 'empty string returns null'); assert.strictEqual(repairTruncatedCardsJson(''), null, 'empty string returns null');
assert.strictEqual(repairTruncatedCardsJson('{"cards":['), null, 'no complete cards returns null'); assert.strictEqual(repairTruncatedCardsJson('{"cards":['), null, 'no complete cards returns null');
const truncJson = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"x","gist":"trunc'; const truncJson =
'{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"x","gist":"trunc';
const repaired = repairTruncatedCardsJson(truncJson); const repaired = repairTruncatedCardsJson(truncJson);
assert.ok(repaired, 'truncated JSON is repaired'); assert.ok(repaired, 'truncated JSON is repaired');
const repairedParsed = JSON.parse(repaired); const repairedParsed = JSON.parse(repaired);
assert.strictEqual(repairedParsed.cards.length, 1, 'only complete card is kept'); assert.strictEqual(repairedParsed.cards.length, 1, 'only complete card is kept');
assert.strictEqual(repairedParsed.cards[0].title, 'A', 'salvaged card has correct title'); assert.strictEqual(repairedParsed.cards[0].title, 'A', 'salvaged card has correct title');
const twoComplete = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"a2","gist":"g2","bullets":["c"]},{"title":"C","anch'; const twoComplete =
'{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"a2","gist":"g2","bullets":["c"]},{"title":"C","anch';
const repairedTwo = JSON.parse(repairTruncatedCardsJson(twoComplete)); const repairedTwo = JSON.parse(repairTruncatedCardsJson(twoComplete));
assert.strictEqual(repairedTwo.cards.length, 2, 'two complete cards salvaged from three'); assert.strictEqual(repairedTwo.cards.length, 2, 'two complete cards salvaged from three');
@ -49,7 +54,8 @@ const repairedSpaced = repairTruncatedCardsJson(spaced);
assert.ok(repairedSpaced, 'repair handles whitespace in cards pattern'); assert.ok(repairedSpaced, 'repair handles whitespace in cards pattern');
assert.strictEqual(JSON.parse(repairedSpaced).cards.length, 1, 'repair with whitespace keeps complete card'); assert.strictEqual(JSON.parse(repairedSpaced).cards.length, 1, 'repair with whitespace keeps complete card');
const escapedQuotes = '{"cards":[{"title":"A \\"quoted\\"","anchor":"a","gist":"g","bullets":["line \\"1\\""]},{"title":"B","anch'; const escapedQuotes =
'{"cards":[{"title":"A \\"quoted\\"","anchor":"a","gist":"g","bullets":["line \\"1\\""]},{"title":"B","anch';
const repairedEscaped = repairTruncatedCardsJson(escapedQuotes); const repairedEscaped = repairTruncatedCardsJson(escapedQuotes);
assert.ok(repairedEscaped, 'repair handles escaped quotes in values'); assert.ok(repairedEscaped, 'repair handles escaped quotes in values');
assert.strictEqual(JSON.parse(repairedEscaped).cards[0].title, 'A "quoted"', 'escaped quotes preserved'); assert.strictEqual(JSON.parse(repairedEscaped).cards[0].title, 'A "quoted"', 'escaped quotes preserved');
@ -67,9 +73,21 @@ assert.strictEqual(repairTruncatedCardsJson('{"cards":[]'), null, 'empty truncat
assert.deepStrictEqual(collectJsonObjectCandidates(''), [], 'empty string gives no candidates'); assert.deepStrictEqual(collectJsonObjectCandidates(''), [], 'empty string gives no candidates');
assert.deepStrictEqual(collectJsonObjectCandidates('no braces'), [], 'no braces gives no candidates'); assert.deepStrictEqual(collectJsonObjectCandidates('no braces'), [], 'no braces gives no candidates');
assert.deepStrictEqual(collectJsonObjectCandidates('{"a":1}'), ['{"a":1}'], 'single object extracted'); assert.deepStrictEqual(collectJsonObjectCandidates('{"a":1}'), ['{"a":1}'], 'single object extracted');
assert.deepStrictEqual(collectJsonObjectCandidates('{"a":1} {"b":2}'), ['{"a":1}', '{"b":2}'], 'multiple objects extracted'); assert.deepStrictEqual(
assert.deepStrictEqual(collectJsonObjectCandidates('{"a":{"b":1}}'), ['{"a":{"b":1}}'], 'nested objects extracted as one'); collectJsonObjectCandidates('{"a":1} {"b":2}'),
assert.deepStrictEqual(collectJsonObjectCandidates('{"a":"{}"}'), ['{"a":"{}"}'], 'braces in strings handled correctly'); ['{"a":1}', '{"b":2}'],
'multiple objects extracted',
);
assert.deepStrictEqual(
collectJsonObjectCandidates('{"a":{"b":1}}'),
['{"a":{"b":1}}'],
'nested objects extracted as one',
);
assert.deepStrictEqual(
collectJsonObjectCandidates('{"a":"{}"}'),
['{"a":"{}"}'],
'braces in strings handled correctly',
);
assert.deepStrictEqual(collectJsonObjectCandidates('{unclosed'), [], 'unclosed brace gives no candidates'); assert.deepStrictEqual(collectJsonObjectCandidates('{unclosed'), [], 'unclosed brace gives no candidates');
// ── normalizeCardsPayload ── // ── normalizeCardsPayload ──
@ -80,12 +98,17 @@ assert.deepStrictEqual(normalizeCardsPayload({ cards: [null, undefined, 42, 'str
assert.deepStrictEqual( assert.deepStrictEqual(
normalizeCardsPayload({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] }), normalizeCardsPayload({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] }),
[{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }], [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }],
'valid card passes through' 'valid card passes through',
); );
assert.deepStrictEqual( assert.deepStrictEqual(
normalizeCardsPayload({ cards: [{ title: 123, gist: null, bullets: [1, 'valid', null] }] }), normalizeCardsPayload({ cards: [{ title: 123, gist: null, bullets: [1, 'valid', null] }] }, { uiLanguage: 'zh' }),
[{ title: '(无标题)', anchor: '', gist: '', bullets: ['valid'] }], [{ title: '(无标题)', anchor: '', gist: '', bullets: ['valid'] }],
'non-string fields get defaults, non-string bullets filtered' 'non-string fields get defaults (zh fallback title), non-string bullets filtered',
);
assert.deepStrictEqual(
normalizeCardsPayload({ cards: [{ title: 123, gist: null, bullets: ['only'] }] }, { uiLanguage: 'en' }),
[{ title: '(Untitled)', anchor: '', gist: '', bullets: ['only'] }],
'fallback title respects en uiLanguage',
); );
console.log('schema tests passed'); console.log('schema tests passed');

View file

@ -7,7 +7,13 @@ assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 300 }), 130, 'small re
assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 1200 }), 180, 'large rect capped at 80'); assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 1200 }), 180, 'large rect capped at 80');
const frames = []; const frames = [];
const throttled = t.createRafThrottledHandler(() => {}, cb => { frames.push(cb); return frames.length; }); const throttled = t.createRafThrottledHandler(
() => {},
(cb) => {
frames.push(cb);
return frames.length;
},
);
throttled(); throttled();
assert.strictEqual(frames.length, 1); assert.strictEqual(frames.length, 1);
throttled.cancel(); throttled.cancel();

View file

@ -9,20 +9,41 @@ assert.throws(() => t.modelForApi({ ...baseSettings, model: '' }), /Model/, 'emp
assert.strictEqual( assert.strictEqual(
t.getApiBaseUrl({ ...baseSettings, apiBaseUrl: 'https://custom.ai/v1/' }), t.getApiBaseUrl({ ...baseSettings, apiBaseUrl: 'https://custom.ai/v1/' }),
'https://custom.ai/v1', 'https://custom.ai/v1',
'strips trailing slash' 'strips trailing slash',
); );
assert.throws( assert.throws(
() => t.getApiBaseUrl({ ...baseSettings, apiProvider: 'custom-openai', apiBaseUrl: '' }), () => t.getApiBaseUrl({ ...baseSettings, apiProvider: 'custom-openai', apiBaseUrl: '' }),
/Custom provider/, /Custom provider/,
'custom provider without url throws' 'custom provider without url throws',
); );
// generationFingerprint stability // generationFingerprint stability
const fp1 = t.generationFingerprint(baseSettings); const fp1 = t.generationFingerprint(baseSettings);
const fp2 = t.generationFingerprint({ ...baseSettings }); const fp2 = t.generationFingerprint({ ...baseSettings });
assert.strictEqual(fp1, fp2, 'same settings = same fingerprint'); assert.strictEqual(fp1, fp2, 'same settings = same fingerprint');
assert.notStrictEqual(fp1, t.generationFingerprint({ ...baseSettings, model: 'other' }), 'different model = different fp'); assert.notStrictEqual(
assert.notStrictEqual(fp1, t.generationFingerprint({ ...baseSettings, apiMaxTokens: 8192 }), 'different tokens = different fp'); fp1,
t.generationFingerprint({ ...baseSettings, model: 'other' }),
'non-codex backend: different model = different fp',
);
// Codex backend: model is ignored by fingerprint (codex uses its own config)
{
const codexA = t.generationFingerprint({ ...baseSettings, backend: 'codex', model: 'gpt-5-codex' });
const codexB = t.generationFingerprint({ ...baseSettings, backend: 'codex', model: 'claude-sonnet-4-6' });
assert.strictEqual(codexA, codexB, 'codex backend: model change does NOT affect fingerprint');
// But other fields still affect codex fingerprint
assert.notStrictEqual(
codexA,
t.generationFingerprint({ ...baseSettings, backend: 'codex', model: 'gpt-5-codex', minCards: 999 }),
'codex backend: other settings still affect fingerprint',
);
}
assert.notStrictEqual(
fp1,
t.generationFingerprint({ ...baseSettings, apiMaxTokens: 8192 }),
'different tokens = different fp',
);
assert.strictEqual(t.normalizeStreamingTimeoutMs('30000'), 30000, 'streaming timeout accepts numeric strings'); assert.strictEqual(t.normalizeStreamingTimeoutMs('30000'), 30000, 'streaming timeout accepts numeric strings');
assert.strictEqual(t.normalizeStreamingTimeoutMs(999), 120000, 'streaming timeout below minimum falls back'); assert.strictEqual(t.normalizeStreamingTimeoutMs(999), 120000, 'streaming timeout below minimum falls back');
assert.strictEqual(t.normalizeStreamingTimeoutMs('bad'), 120000, 'streaming timeout rejects non-numeric values'); assert.strictEqual(t.normalizeStreamingTimeoutMs('bad'), 120000, 'streaming timeout rejects non-numeric values');
@ -45,23 +66,67 @@ assert.strictEqual(normalized.maxCards, 30, 'normalizeSettings clamps maxCards t
const crypto = require('crypto'); const crypto = require('crypto');
const hash = crypto.createHash('sha1').update('test', 'utf8').digest('hex'); const hash = crypto.createHash('sha1').update('test', 'utf8').digest('hex');
assert.strictEqual( assert.strictEqual(
t.cacheEntryMatches({ t.cacheEntryMatches(
schemaVersion: t.CACHE_SCHEMA_VERSION, {
contentHash: hash, schemaVersion: t.CACHE_SCHEMA_VERSION,
settingsHash: t.generationFingerprint(baseSettings), contentHash: hash,
}, 'test', baseSettings), settingsHash: t.generationFingerprint(baseSettings),
true, 'matching entry returns true' },
'test',
baseSettings,
),
true,
'matching entry returns true',
); );
assert.strictEqual(t.cacheEntryMatches(null, 'test', baseSettings), false, 'null entry returns false'); assert.strictEqual(t.cacheEntryMatches(null, 'test', baseSettings), false, 'null entry returns false');
assert.strictEqual( assert.strictEqual(
t.shouldSkipBatchFile({ t.shouldSkipBatchFile(
schemaVersion: t.CACHE_SCHEMA_VERSION, {
contentHash: hash, schemaVersion: t.CACHE_SCHEMA_VERSION,
settingsHash: t.generationFingerprint(baseSettings), contentHash: hash,
}, 'test', baseSettings), settingsHash: t.generationFingerprint(baseSettings),
},
'test',
baseSettings,
),
true, true,
'fresh cache entry is skipped during batch', 'fresh cache entry is skipped during batch',
); );
assert.strictEqual(t.shouldSkipBatchFile(null, 'test', baseSettings), false, 'missing cache entry is not skipped'); assert.strictEqual(t.shouldSkipBatchFile(null, 'test', baseSettings), false, 'missing cache entry is not skipped');
// normalizeCardCount: cap to 30, fall back on invalid
assert.strictEqual(t.normalizeCardCount(50, 5), 30, 'card count over 30 is capped');
assert.strictEqual(t.normalizeCardCount(0, 5), 1, 'card count 0 floors to 1');
assert.strictEqual(t.normalizeCardCount(-1, 5), 1, 'negative card count floors to 1');
assert.strictEqual(t.normalizeCardCount('bad', 5), 5, 'non-numeric card count falls back to default');
assert.strictEqual(t.normalizeCardCount('15', 5), 15, 'numeric string accepted');
// normalizeCliTimeoutMs (mirrors normalizeStreamingTimeoutMs)
assert.strictEqual(t.normalizeCliTimeoutMs('60000'), 60000, 'cli timeout accepts numeric strings');
assert.strictEqual(t.normalizeCliTimeoutMs(999), 300000, 'cli timeout below minimum falls back to default');
assert.strictEqual(t.normalizeCliTimeoutMs('bad'), 300000, 'cli timeout rejects non-numeric values');
assert.strictEqual(
t.normalizeSettings({ ...baseSettings, cliTimeoutMs: 500 }).cliTimeoutMs,
300000,
'normalizeSettings protects invalid cli timeout values',
);
// applyApiProviderPreset clears credentials but keeps preset baseUrl (security: prevent cross-provider key leak)
{
const previous = {
...baseSettings,
apiProvider: 'anthropic',
apiKey: 'sk-ant-secret',
apiHeaders: 'Authorization: Bearer leaked',
apiBaseUrl: 'https://api.anthropic.com/v1',
};
const switched = t.applyApiProviderPreset(previous, 'openai');
assert.strictEqual(switched.apiKey, '', 'switching provider clears apiKey');
assert.strictEqual(switched.apiHeaders, '', 'switching provider clears apiHeaders');
assert.notStrictEqual(switched.apiBaseUrl, '', 'switching provider keeps preset baseUrl (not blank)');
assert.ok(switched.apiBaseUrl.includes('openai'), 'switched apiBaseUrl matches new openai preset');
assert.notStrictEqual(switched, previous, 'applyApiProviderPreset returns new object');
assert.strictEqual(previous.apiKey, 'sk-ant-secret', 'applyApiProviderPreset does not mutate input');
}
console.log('settings tests passed'); console.log('settings tests passed');

View file

@ -4,29 +4,30 @@ const { assert, t, baseSettings } = require('./test-setup');
assert.strictEqual( assert.strictEqual(
t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'openai-chat' }), t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'openai-chat' }),
true, 'OpenAI Chat supports streaming' true,
'OpenAI Chat supports streaming',
); );
assert.strictEqual( assert.strictEqual(
t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'anthropic-messages', apiProvider: 'anthropic' }), t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'anthropic-messages', apiProvider: 'anthropic' }),
true, 'Anthropic Messages supports streaming' true,
'Anthropic Messages supports streaming',
); );
assert.strictEqual( assert.strictEqual(
t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'google-generative-ai', apiProvider: 'google' }), t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'google-generative-ai', apiProvider: 'google' }),
false, 'Gemini does not support streaming' false,
'Gemini does not support streaming',
); );
assert.strictEqual( assert.strictEqual(
t.supportsStreaming({ ...baseSettings, streaming: false, apiFormat: 'openai-chat' }), t.supportsStreaming({ ...baseSettings, streaming: false, apiFormat: 'openai-chat' }),
false, 'streaming disabled returns false' false,
'streaming disabled returns false',
); );
// ── deltaExtractorForFormat ── // ── deltaExtractorForFormat ──
const openaiExtract = t.deltaExtractorForFormat('openai-chat'); const openaiExtract = t.deltaExtractorForFormat('openai-chat');
assert.ok(openaiExtract, 'openai-chat extractor exists'); assert.ok(openaiExtract, 'openai-chat extractor exists');
assert.strictEqual( assert.strictEqual(openaiExtract({ choices: [{ delta: { content: 'hello' } }] }), 'hello', 'extracts OpenAI delta');
openaiExtract({ choices: [{ delta: { content: 'hello' } }] }),
'hello', 'extracts OpenAI delta'
);
assert.strictEqual(openaiExtract({ choices: [{ delta: {} }] }), '', 'empty delta'); assert.strictEqual(openaiExtract({ choices: [{ delta: {} }] }), '', 'empty delta');
assert.strictEqual(openaiExtract({}), '', 'missing choices'); assert.strictEqual(openaiExtract({}), '', 'missing choices');
@ -34,12 +35,10 @@ const anthropicExtract = t.deltaExtractorForFormat('anthropic-messages');
assert.ok(anthropicExtract, 'anthropic extractor exists'); assert.ok(anthropicExtract, 'anthropic extractor exists');
assert.strictEqual( assert.strictEqual(
anthropicExtract({ type: 'content_block_delta', delta: { text: 'world' } }), anthropicExtract({ type: 'content_block_delta', delta: { text: 'world' } }),
'world', 'extracts Anthropic delta' 'world',
); 'extracts Anthropic delta',
assert.strictEqual(
anthropicExtract({ type: 'message_start' }),
'', 'non-delta event returns empty'
); );
assert.strictEqual(anthropicExtract({ type: 'message_start' }), '', 'non-delta event returns empty');
assert.strictEqual(t.deltaExtractorForFormat('google-generative-ai'), null, 'no extractor for gemini'); assert.strictEqual(t.deltaExtractorForFormat('google-generative-ai'), null, 'no extractor for gemini');
@ -79,10 +78,7 @@ const sseMultilineData = t.parseSseBuffer(
); );
assert.deepStrictEqual(sseMultilineData.deltas, ['joined'], 'consecutive data lines are merged into one event'); assert.deepStrictEqual(sseMultilineData.deltas, ['joined'], 'consecutive data lines are merged into one event');
const ssePartialEvent = t.parseSseBuffer( const ssePartialEvent = t.parseSseBuffer('data: {"choices":[{"delta":{"content":"wait"}}]}\n', openaiExtract);
'data: {"choices":[{"delta":{"content":"wait"}}]}\n',
openaiExtract,
);
assert.deepStrictEqual(ssePartialEvent.deltas, [], 'event without blank-line terminator is retained'); assert.deepStrictEqual(ssePartialEvent.deltas, [], 'event without blank-line terminator is retained');
assert.strictEqual(ssePartialEvent.rest.includes('"wait"'), true, 'partial event remains in rest'); assert.strictEqual(ssePartialEvent.rest.includes('"wait"'), true, 'partial event remains in rest');
@ -95,4 +91,19 @@ assert.deepStrictEqual(sseMixed.deltas, ['x'], 'comment and event lines ignored'
const sseBad = t.parseSseBuffer('data: not_json\n\ndata: {"choices":[{"delta":{"content":"y"}}]}\n\n', openaiExtract); const sseBad = t.parseSseBuffer('data: not_json\n\ndata: {"choices":[{"delta":{"content":"y"}}]}\n\n', openaiExtract);
assert.deepStrictEqual(sseBad.deltas, ['y'], 'bad JSON line skipped, good line extracted'); assert.deepStrictEqual(sseBad.deltas, ['y'], 'bad JSON line skipped, good line extracted');
// ── parseSseBuffer flush behavior: appending \n\n to unterminated event ──
// (used by doStreamingFetch on EOF when provider closes without trailing \n\n)
{
const unterminated = 'data: {"choices":[{"delta":{"content":"final"}}]}';
const flushed = t.parseSseBuffer(`${unterminated}\n\n`, openaiExtract);
assert.deepStrictEqual(flushed.deltas, ['final'], 'flush of unterminated event extracts last delta');
assert.strictEqual(flushed.rest, '', 'flush leaves no remainder');
}
{
// Buffer ending with single \n (often a partial line) — flush by appending one more \n
const partial = 'data: {"choices":[{"delta":{"content":"tail"}}]}\n';
const flushed = t.parseSseBuffer(`${partial}\n`, openaiExtract);
assert.deepStrictEqual(flushed.deltas, ['tail'], 'single-newline buffer flushed correctly');
}
console.log('streaming tests passed'); console.log('streaming tests passed');

View file

@ -0,0 +1,49 @@
const { assert, t } = require('./test-setup');
const expectedExports = [
'cardsToMarkdown',
'cancellationNoticeKey',
'summarizeDocument',
'addIconButton',
'addTextButton',
'copyToClipboard',
'resolveCliPath',
'runCli',
'buildPrompts',
'buildOpenAiChatBody',
'buildLineOffsets',
'extractJson',
'findLineForAnchor',
'folderPathsForTarget',
'getApiBaseUrl',
'generationFingerprint',
'hasUnsafeBatchFolderSegments',
'CacheManager',
'GenerationJobManager',
'createBatchRunState',
'modelForApi',
'activeSectionLine',
'touchCacheEntry',
'nextCardIndex',
'pruneCacheEntries',
'removeCardAt',
'activeIndexAfterCardDelete',
'createRafThrottledHandler',
'visibleTopProbeY',
'serializeCacheFile',
'shouldConfirmRegenerate',
'translate',
'updateCardAt',
'validateBatchFolderInput',
'normalizeSettings',
'normalizeStreamingTimeoutMs',
'normalizeCliTimeoutMs',
'normalizeCardCount',
'applyApiProviderPreset',
];
for (const name of expectedExports) {
assert.strictEqual(typeof t[name], 'function', `test-exports should include ${name}`);
}
console.log('test exports tests passed');

View file

@ -1,40 +1,67 @@
const assert = require('assert'); const assert = require('assert');
const esbuild = require('esbuild');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const Module = require('module');
const originalLoad = Module._load; // Polyfill Obsidian's `activeWindow` global for Node test runtime.
let requestUrlMock = async () => ({ status: 200, json: {}, text: '{}' }); if (typeof globalThis.activeWindow === 'undefined') {
globalThis.activeWindow = globalThis;
}
Module._load = function load(request, parent, isMain) { // Load obsidian mock first — hooks Module._load so that require('obsidian')
if (request === 'obsidian') { // returns the mock for both us and the esbuild-bundled test-exports module.
class Plugin {} const { getRequestUrlMock, setRequestUrlMock } = require('./obsidian-mock');
class ItemView { constructor(leaf) { this.leaf = leaf; this.containerEl = { children: [{}, {}] }; } }
class PluginSettingTab {} // Bundle test-exports.ts synchronously with obsidian marked as external.
class Setting {} // The bundled code will require('obsidian') at runtime, hitting our mock.
class Notice {} const repoRoot = path.join(__dirname, '..');
class MarkdownView {} // Under c8, bundles must live inside the repo so c8 processes their coverage
class TFile {} // (it ignores scripts outside cwd before source-map remap). Otherwise /tmp.
class Menu {} const bundleParent = process.env.NODE_V8_COVERAGE
class Modal {} ? fs.mkdirSync(path.join(repoRoot, '.test-bundles'), { recursive: true }) || path.join(repoRoot, '.test-bundles')
return { : os.tmpdir();
Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, Modal, const tempDir = fs.mkdtempSync(path.join(bundleParent, 'parallel-reader-test-setup-'));
MarkdownRenderer: { render: async () => {} }, const outfile = path.join(tempDir, 'test-exports.cjs');
requestUrl: (params) => requestUrlMock(params),
setIcon: () => {}, esbuild.buildSync({
}; entryPoints: [path.join(repoRoot, 'src/test-exports.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
external: ['obsidian'],
sourcemap: 'inline',
sourcesContent: true,
sourceRoot: repoRoot,
});
require('./coverage-sourcemap').fixInlineSourceMap(outfile, repoRoot);
const t = require(outfile);
// Clean up temp bundle on process exit. Skipped under c8 coverage:
// c8 reads V8 coverage at exit and needs the bundled file (with its
// inline source map) still on disk to remap back to src/*.ts.
process.on('exit', () => {
if (process.env.NODE_V8_COVERAGE) return;
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (_) {
// Best-effort cleanup.
} }
return originalLoad.call(this, request, parent, isMain); });
};
const t = require('../main.js').__test;
function openAiCardsResponse(cards) { function openAiCardsResponse(cards) {
const json = { const json = {
choices: [{ choices: [
message: { {
content: JSON.stringify({ cards }), message: {
content: JSON.stringify({ cards }),
},
}, },
}], ],
}; };
return { status: 200, json, text: JSON.stringify(json) }; return { status: 200, json, text: JSON.stringify(json) };
} }
@ -56,6 +83,6 @@ module.exports = {
t, t,
baseSettings, baseSettings,
openAiCardsResponse, openAiCardsResponse,
getRequestUrlMock() { return requestUrlMock; }, getRequestUrlMock,
setRequestUrlMock(fn) { requestUrlMock = fn; }, setRequestUrlMock,
}; };

View file

@ -41,7 +41,11 @@ const batchFiles = [
{ path: 'Reading/note.md', parent: { path: 'Reading' } }, { path: 'Reading/note.md', parent: { path: 'Reading' } },
{ path: 'Reading/Deep/nested.md', parent: { path: 'Reading/Deep' } }, { path: 'Reading/Deep/nested.md', parent: { path: 'Reading/Deep' } },
]; ];
assert.deepStrictEqual(t.selectBatchFiles(batchFiles, '').map((file) => file.path), ['root.md'], 'root folder only'); assert.deepStrictEqual(
t.selectBatchFiles(batchFiles, '').map((file) => file.path),
['root.md'],
'root folder only',
);
assert.deepStrictEqual( assert.deepStrictEqual(
t.selectBatchFiles(batchFiles, '/Reading/').map((file) => file.path), t.selectBatchFiles(batchFiles, '/Reading/').map((file) => file.path),
['Reading/note.md'], ['Reading/note.md'],
@ -54,7 +58,11 @@ const processedStats = t.recordBatchProcessed(skippedStats);
const errorStats = t.recordBatchError(processedStats); const errorStats = t.recordBatchError(processedStats);
assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0, errors: 0 }, 'batch stats are immutable'); assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0, errors: 0 }, 'batch stats are immutable');
assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1, errors: 0 }, 'batch skip updates progress'); assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1, errors: 0 }, 'batch skip updates progress');
assert.deepStrictEqual(processedStats, { total: 3, processed: 2, skipped: 1, errors: 0 }, 'batch processed count accumulates'); assert.deepStrictEqual(
processedStats,
{ total: 3, processed: 2, skipped: 1, errors: 0 },
'batch processed count accumulates',
);
assert.deepStrictEqual(errorStats, { total: 3, processed: 3, skipped: 1, errors: 1 }, 'batch error count accumulates'); assert.deepStrictEqual(errorStats, { total: 3, processed: 3, skipped: 1, errors: 1 }, 'batch error count accumulates');
const batchState = t.createBatchRunState(); const batchState = t.createBatchRunState();

542
tests/view-render.test.js Normal file
View file

@ -0,0 +1,542 @@
/**
* Component-level tests for plugin/view interaction paths.
* These cover regressions that pure unit tests cannot catch the bugs only
* manifest when state on `view` (sourceFile) and runForFile options
* (silentView) interact in specific combinations.
*/
const { assert, t } = require('./test-setup');
const { CACHE_SCHEMA_VERSION, generationFingerprint } = t;
const crypto = require('crypto');
const hashContent = (text) => crypto.createHash('sha1').update(text, 'utf8').digest('hex');
// Settings used by all tests in this file. Anthropic backend by default so we
// never accidentally hit any HTTP code path — the cache-hit path returns before
// summarizeDocument is called.
function makeSettings() {
return {
uiLanguage: 'en',
backend: 'api',
cliPath: '',
apiProvider: 'anthropic',
apiFormat: 'anthropic-messages',
apiBaseUrl: 'https://api.anthropic.com/v1',
apiKey: 'test',
apiKeyEnvVar: '',
apiAuthType: 'x-api-key',
apiHeaders: '',
apiMaxTokens: 4096,
maxDocChars: 100000,
maxCacheEntries: 100,
promptLanguage: 'en',
minCards: 5,
maxCards: 15,
customSystemPrompt: '',
model: 'claude-sonnet-4-6',
exportFolder: 'Reading/Articles',
cliTimeoutMs: 120000,
streaming: false,
streamingTimeoutMs: 120000,
};
}
function makeFakeView() {
const calls = { renderLoading: [], renderError: [], renderEmpty: 0, loadFor: [], renderStreamingPreview: [] };
const view = {
sourceFile: null,
sections: [],
renderLoading(file, msg) {
this.sourceFile = file;
calls.renderLoading.push([file?.path, msg]);
},
renderError(file, msg) {
this.sourceFile = file;
calls.renderError.push([file?.path, msg]);
},
renderEmpty() {
this.sourceFile = null;
calls.renderEmpty++;
},
loadFor(file, sections) {
this.sourceFile = file;
this.sections = sections;
calls.loadFor.push([file.path, sections.length]);
},
renderStreamingPreview(file, text) {
this.sourceFile = file;
calls.renderStreamingPreview.push([file.path, text.length]);
},
};
return { view, calls };
}
function makeFakeFile(path) {
return { path, basename: path.replace(/\.md$/, '') };
}
function makeBasePlugin(settings, { view, leaves, vaultRead } = {}) {
const plugin = new t.ParallelReaderPlugin();
plugin.settings = settings;
plugin.t = (key) => key;
plugin.jobs = new t.GenerationJobManager();
plugin.cacheManager = {
cache: {},
get: () => null,
put: async () => {},
touch: () => null,
delete: async () => {},
clear: async () => {},
};
plugin.app = {
workspace: {
getLeavesOfType: () => leaves || [],
getRightLeaf: () => null,
revealLeaf: async () => {},
getActiveViewOfType: () => null,
on: () => ({}),
},
vault: {
read: vaultRead || (async () => 'hello world content'),
adapter: {},
configDir: '.obsidian',
getMarkdownFiles: () => [],
getAbstractFileByPath: () => null,
on: () => ({}),
},
};
// Default getParallelView returns the supplied view if any.
plugin.getParallelView = () => view;
// Default getActiveView (used by activeFileStillMatches) returns null → match.
plugin.getActiveView = () => null;
return plugin;
}
/* ============================================================
* shouldRender matrix: silentView × view.sourceFile state
* Regression coverage for 1.0.13 (bug 1).
* Uses the cache-hit code path (no LLM call) to focus on the render guard.
* ============================================================ */
async function testShouldRender_NonSilent_FreshView() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileA = makeFakeFile('A.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// sourceFile=null (panel never showed anything yet)
view.sourceFile = null;
const result = await plugin.runForFile(fileA, false);
assert.strictEqual(result, 'cached', 'cache-hit returns cached');
assert.strictEqual(calls.loadFor.length, 1, 'non-silent + fresh view → loadFor must fire (1.0.12 regression)');
assert.strictEqual(calls.loadFor[0][0], 'A.md', 'loadFor passed correct file');
}
async function testShouldRender_NonSilent_DifferentFile() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileB = makeFakeFile('B.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// panel was showing fileA, user generates fileB via hotkey
view.sourceFile = makeFakeFile('A.md');
await plugin.runForFile(fileB, false);
assert.strictEqual(
calls.loadFor.length,
1,
'non-silent + different sourceFile → loadFor must fire (1.0.12 regression)',
);
assert.strictEqual(calls.loadFor[0][0], 'B.md', 'loadFor switched to file B');
}
async function testShouldRender_Silent_DifferentFile_Skipped() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileB = makeFakeFile('B.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// batch path: panel shows A, batch processes B → must NOT disturb panel
view.sourceFile = makeFakeFile('A.md');
const result = await plugin.runForFile(fileB, false, { silentView: true });
assert.strictEqual(result, 'cached');
assert.strictEqual(calls.loadFor.length, 0, 'silentView + different file → must NOT render (preserve user focus)');
}
async function testShouldRender_Silent_SameFile_Updates() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileA = makeFakeFile('A.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// batch path but the panel happens to be showing the file currently being processed
view.sourceFile = fileA;
await plugin.runForFile(fileA, false, { silentView: true });
assert.strictEqual(calls.loadFor.length, 1, 'silentView + same file → render IS allowed');
assert.strictEqual(calls.loadFor[0][0], 'A.md');
}
/* ============================================================
* toggleParallelView: open close open
* Regression coverage for 1.0.13 (bug 2).
* ============================================================ */
async function testToggleParallelView_NoLeaf_Opens() {
const settings = makeSettings();
let setViewStateCalled = 0;
let revealLeafCalled = 0;
const fakeLeaf = {
view: { containerEl: { children: [{}, {}] } },
setViewState: async () => {
setViewStateCalled++;
},
};
const plugin = makeBasePlugin(settings, { leaves: [] });
plugin.app.workspace.getRightLeaf = () => fakeLeaf;
plugin.app.workspace.revealLeaf = async () => {
revealLeafCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(setViewStateCalled, 1, 'first toggle creates leaf via setViewState');
assert.strictEqual(revealLeafCalled, 1, 'first toggle reveals leaf');
}
async function testToggleParallelView_LeafExists_SidebarOpen_Collapses() {
const settings = makeSettings();
let detachCalled = 0;
let collapseCalled = 0;
let revealCalled = 0;
const fakeLeaf = { detach: () => detachCalled++, view: {} };
const plugin = makeBasePlugin(settings, { leaves: [fakeLeaf] });
plugin.app.workspace.rightSplit = {
collapsed: false,
collapse: () => collapseCalled++,
};
plugin.app.workspace.revealLeaf = async () => {
revealCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(detachCalled, 0, 'leaf must NOT be detached (preserve tab content)');
assert.strictEqual(collapseCalled, 1, 'expanded sidebar → collapse');
assert.strictEqual(revealCalled, 0, 'no reveal when collapsing');
}
async function testToggleParallelView_LeafExists_SidebarCollapsed_Reveals() {
const settings = makeSettings();
let detachCalled = 0;
let collapseCalled = 0;
let revealCalled = 0;
const fakeLeaf = { detach: () => detachCalled++, view: {} };
const plugin = makeBasePlugin(settings, { leaves: [fakeLeaf] });
plugin.app.workspace.rightSplit = {
collapsed: true,
collapse: () => collapseCalled++,
};
plugin.app.workspace.revealLeaf = async () => {
revealCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(detachCalled, 0, 'leaf must NOT be detached');
assert.strictEqual(collapseCalled, 0, 'no collapse when sidebar already collapsed');
assert.strictEqual(revealCalled, 1, 'collapsed sidebar → reveal expands it and focuses our tab');
}
async function testToggleParallelView_NoRightSplit_FallsBackToReveal() {
const settings = makeSettings();
let revealCalled = 0;
const fakeLeaf = { detach: () => {}, view: {} };
const plugin = makeBasePlugin(settings, { leaves: [fakeLeaf] });
plugin.app.workspace.rightSplit = undefined;
plugin.app.workspace.revealLeaf = async () => {
revealCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(revealCalled, 1, 'no right sidebar (mobile?) → fallback to revealLeaf');
}
/* ============================================================
* refreshViewAfterCacheDelete / refreshViewAfterCacheClear
* Regression coverage for PR2 #14 (clear cache UI not refreshed).
* ============================================================ */
async function testRefreshViewAfterCacheDelete_MatchingFile() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
view.sourceFile = makeFakeFile('A.md');
const plugin = makeBasePlugin(settings, { view });
plugin.refreshViewAfterCacheDelete('A.md');
assert.strictEqual(calls.renderEmpty, 1, 'view showing the cleared file → renderEmpty');
}
async function testRefreshViewAfterCacheDelete_DifferentFile() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
view.sourceFile = makeFakeFile('A.md');
const plugin = makeBasePlugin(settings, { view });
plugin.refreshViewAfterCacheDelete('B.md'); // different file
assert.strictEqual(calls.renderEmpty, 0, 'view showing different file → must NOT renderEmpty');
}
async function testRefreshViewAfterCacheClear_AlwaysClears() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
view.sourceFile = makeFakeFile('A.md');
const plugin = makeBasePlugin(settings, { view });
plugin.refreshViewAfterCacheClear();
assert.strictEqual(calls.renderEmpty, 1, 'clear-all → renderEmpty regardless of file');
}
/* ============================================================
* runForFile outcome enum mapping drives batch statistics.
* Covers the catch-block branches that were untested before.
* ============================================================ */
async function testRunForFile_AlreadyRunning_EarlyReturn() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = { isPending: () => true, isRunning: () => true };
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'already-running', 'isPending=true returns early with already-running');
}
async function testRunForFile_AlreadyRunning_FromCatch() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new t.GenerationJobAlreadyRunningError('A.md');
},
};
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'already-running', 'GenerationJobAlreadyRunningError from start → already-running');
}
async function testRunForFile_Cancelled_FromCatch() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new t.GenerationJobCancelledError('A.md');
},
};
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'cancelled', 'GenerationJobCancelledError → cancelled');
}
async function testRunForFile_GenericError_FromCatch() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new Error('backend down');
},
};
const origError = console.error;
console.error = () => {};
try {
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'error', 'plain Error → error');
} finally {
console.error = origError;
}
}
async function testRunForFile_RegenerateConfirm_Cancels() {
const plugin = makeBasePlugin(makeSettings());
// shouldConfirmRegenerate triggers when entry has user edits — simulate via cache entry shape.
plugin.cacheManager.get = () => ({
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: 'x',
settingsHash: 'y',
cards: [],
generatedAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z', // user edited after generation → confirm prompt
});
plugin.confirmRegenerateEditedCards = async () => false; // user clicks cancel
const result = await plugin.runForFile(makeFakeFile('A.md'), true);
assert.strictEqual(result, 'cancelled', 'user-cancelled regenerate confirm → cancelled');
}
async function testRunForFile_SkipEditConfirm_BypassesPrompt() {
const plugin = makeBasePlugin(makeSettings());
plugin.cacheManager.get = () => ({
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: 'x',
settingsHash: 'y',
cards: [],
generatedAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
});
let confirmCalled = 0;
plugin.confirmRegenerateEditedCards = async () => {
confirmCalled++;
return false;
};
// Make jobs.start succeed so we can verify confirm was skipped.
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new Error('downstream'); // any quick exit
},
};
const origError = console.error;
console.error = () => {};
try {
await plugin.runForFile(makeFakeFile('A.md'), true, { skipEditConfirm: true });
assert.strictEqual(confirmCalled, 0, 'skipEditConfirm=true must bypass confirm prompt (batch flow)');
} finally {
console.error = origError;
}
}
/* ============================================================
* view.deleteCard / view.updateCard cardPersistFailed path
* Covers the case where cacheReplaceCards returns false (cache missing)
* and verifies that user is notified of the failure.
* ============================================================ */
async function testViewDeleteCard_PersistFails_ReturnsFalse() {
const plugin = makeBasePlugin(makeSettings());
let cacheReplaceCalled = null;
plugin.cacheReplaceCards = async (path, sections) => {
cacheReplaceCalled = { path, count: sections.length };
return false; // simulate missing cache entry
};
const fakeLeaf = { view: {} };
const view = new t.ParallelReaderView(fakeLeaf, plugin);
view.render = () => {}; // skip DOM
view.sourceFile = makeFakeFile('A.md');
view.sections = [
{ title: 'a', anchor: '', gist: '', bullets: [], startLine: 0, level: 0 },
{ title: 'b', anchor: '', gist: '', bullets: [], startLine: 1, level: 0 },
];
view.activeIdx = 0;
const ok = await view.deleteCard(0);
assert.strictEqual(ok, false, 'deleteCard returns false when cache persist fails');
assert.deepStrictEqual(cacheReplaceCalled, { path: 'A.md', count: 1 }, 'cacheReplaceCards called with new sections');
assert.strictEqual(view.sections.length, 1, 'view.sections is updated locally even on persist failure');
}
async function testViewDeleteCard_PersistOk_ReturnsTrue() {
const plugin = makeBasePlugin(makeSettings());
plugin.cacheReplaceCards = async () => true;
const fakeLeaf = { view: {} };
const view = new t.ParallelReaderView(fakeLeaf, plugin);
view.render = () => {};
view.sourceFile = makeFakeFile('A.md');
view.sections = [
{ title: 'a', anchor: '', gist: '', bullets: [], startLine: 0, level: 0 },
{ title: 'b', anchor: '', gist: '', bullets: [], startLine: 1, level: 0 },
];
view.activeIdx = 0;
const ok = await view.deleteCard(0);
assert.strictEqual(ok, true, 'deleteCard returns true on successful persist');
}
async function testViewUpdateCard_PersistFails_ReturnsFalse() {
const plugin = makeBasePlugin(makeSettings());
plugin.cacheReplaceCards = async () => false;
const fakeLeaf = { view: {} };
const view = new t.ParallelReaderView(fakeLeaf, plugin);
view.render = () => {};
view.sourceFile = makeFakeFile('A.md');
view.sections = [{ title: 'a', anchor: '', gist: '', bullets: [], startLine: 0, level: 0 }];
view.activeIdx = 0;
const ok = await view.updateCard(0, { title: 'a-edited' });
assert.strictEqual(ok, false, 'updateCard returns false on persist failure');
}
async function testRefreshViewAfterCacheClear_NoView() {
const settings = makeSettings();
const plugin = makeBasePlugin(settings, { view: undefined });
plugin.getParallelView = () => undefined;
// Must not throw when no view exists.
plugin.refreshViewAfterCacheClear();
}
(async () => {
await testShouldRender_NonSilent_FreshView();
await testShouldRender_NonSilent_DifferentFile();
await testShouldRender_Silent_DifferentFile_Skipped();
await testShouldRender_Silent_SameFile_Updates();
await testToggleParallelView_NoLeaf_Opens();
await testToggleParallelView_LeafExists_SidebarOpen_Collapses();
await testToggleParallelView_LeafExists_SidebarCollapsed_Reveals();
await testToggleParallelView_NoRightSplit_FallsBackToReveal();
await testRefreshViewAfterCacheDelete_MatchingFile();
await testRefreshViewAfterCacheDelete_DifferentFile();
await testRefreshViewAfterCacheClear_AlwaysClears();
await testRefreshViewAfterCacheClear_NoView();
await testRunForFile_AlreadyRunning_EarlyReturn();
await testRunForFile_AlreadyRunning_FromCatch();
await testRunForFile_Cancelled_FromCatch();
await testRunForFile_GenericError_FromCatch();
await testRunForFile_RegenerateConfirm_Cancels();
await testRunForFile_SkipEditConfirm_BypassesPrompt();
await testViewDeleteCard_PersistFails_ReturnsFalse();
await testViewDeleteCard_PersistOk_ReturnsTrue();
await testViewUpdateCard_PersistFails_ReturnsFalse();
console.log('view-render tests passed');
})().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"target": "ES2020", "target": "ES2022",
"module": "CommonJS", "module": "CommonJS",
"moduleResolution": "Node", "moduleResolution": "Node",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],

View file

@ -1,10 +1,27 @@
{ {
"1.0.0": "1.4.0", "1.0.0": "1.4.0",
"1.0.1": "1.4.0", "1.0.1": "1.4.0",
"1.0.2": "1.4.0", "1.0.2": "1.4.0",
"1.0.3": "1.4.0", "1.0.3": "1.4.0",
"1.0.4": "1.4.0", "1.0.4": "1.4.0",
"1.0.5": "1.4.0", "1.0.5": "1.4.0",
"1.0.6": "1.4.0", "1.0.6": "1.4.0",
"1.0.7": "1.4.0" "1.0.7": "1.4.0",
"1.0.8": "1.4.0",
"1.0.9": "1.4.0",
"1.0.10": "1.4.0",
"1.0.11": "1.4.0",
"1.0.12": "1.4.0",
"1.0.13": "1.4.0",
"1.0.14": "1.4.0",
"1.0.15": "1.7.2",
"1.0.16": "1.7.2",
"1.0.17": "1.7.2",
"1.0.18": "1.8.7",
"1.0.19": "1.8.7",
"1.0.20": "1.8.7",
"1.0.21": "1.8.7",
"1.0.22": "1.8.7",
"1.0.23": "1.8.7",
"1.0.24": "1.8.7"
} }