mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
* fix(process): normalize reactive rendered-frame timestamps A reactive `resize` action stamped `rendered_frames[].at_ms` with the raw elapsed time while every other rendered frame used `normalizeProcessElapsedTime`. Under the default 10ms time bucket the next frame buckets down and appears to move backwards, so `parseProcessCapture` rejected the entire capture with "rendered_frames: timestamps must be ordered". No test combined a reactive resize with time bucketing, so the path was never exercised. Thread the scenario's bucket into the reactive effect host and normalize the renderer call, matching what the scheduled-event resize path already did. `dispatched_at_ms` and `captured_at_ms` stay raw on both paths, so this changes only the one inconsistent clock. Also replace the wall-clock-scheduled interactive PTY scenario with terminal_text triggers. A fixed `at_ms` could fire before the child wrote its prompt under host contention, dropping the input and the resize echo. That rewrite is what surfaced this bug, and it now passes deterministically. Replace the 11 `if (!capability.available) return;` guards across the PTY suite with a shared it.skipIf gate. The old form reported a green pass with zero assertions on any host lacking native PTY authority; skips are now named and reported. Verified by reverting only the normalization, which reproduces the validation failure immediately. * fix(install): add recovery guidance to version-mismatch failure Every installer failure except one told the user what to do next. The version-mismatch failure reported only that the installed version did not match, with no remedy, which contradicts the requirement to keep diagnostics actionable for mismatch locations. Add a remedy in the same style as the neighbouring failures and drop the exemption the test carried for it, so all ten installer failures now assert they carry a recovery action. * ci: repair stale curated Windows lane and unblind the duplication gate The `windows-curated` job listed fifteen test files and nine of them no longer existed, including six `processOwnership*` files moved to `src/process/` in4ecc7a72. Vitest silently drops unmatched paths, so the job ran six files and reported success. Repoint the paths at their current locations and add a test that fails when any listed path is renamed or removed, so the lane cannot silently lose coverage again. The duplication gate ignored `**/*.test.ts`, so it reported zero clones across the test tree while running on every PR. Include test files, remove the two largest duplicated clusters, and ratchet the threshold to the measured result so the gate can no longer pass by not looking. Run local tests with two workers instead of one. A single worker made a full local run cost ~140s of wall time at ~100% CPU on a 10-core host, which was the largest dev-cycle cost in the repository. Local now matches the concurrency CI already proves green, and PTY scenarios moved to a serial project because they contend for host terminal resources. Full local suite: 140s to ~89s. Regenerate the managed conformance manifest. Commit4ecc7a72edited SKILL.md without refreshing the digest, so `npm run check:pr` has been failing on main since that commit. * test: replace implementation lock-in with behavioural assertions Audit the suite for tests that cost maintenance without catching defects, then remove the lock-in rather than the coverage. Replace assertions that froze incidental detail: - toolContracts.test.ts pinned group sizes and 50 lines of literal tool names, so any additive catalog change required a test edit. Replace with four structural invariants: global name uniqueness, group containment in the published inventory, group disjointness, and non-empty groups. Both invariants are mutation-checked by orphaning and duplicating a tool. - Six tests asserted only that a schema accepts its own canonical fixture. Replace five with real rejections and two with property tests covering the invariants those tests were gesturing at: `isDbSaveComplete` is downward closed in every expected count, and frame retention holds at any size. - A concurrency test whose fake returned `? [] : []` could not fail. Give the fake real shared state so a lost update is observable. - providerProcess.test.ts asserted listener and timer counts plus exact cleanup retry counts. Assert detachment behaviour instead, so an unrelated internal timer no longer breaks the test. - Scale tests that rebuilt removed caps: 16 MiB and 10,001 files to prove limits that no longer exist, 100,001 frames compared against their own length, and a projection asserting a literal 10,201 bridge candidates. Replace with derived relationships that hold at any size. - installScript.test.ts merged two tables and now asserts alongside the exact wording that every failure carries a recovery action. - cdpBrowserProvider.sensitive_data asserted outcomes produced after the observation window closes while using a zero-length window; give it a real window so it measures the malformed-body rejection rather than host speed. - runtime.test.ts repeated the catalog assertion from the MCP boundary suite while spawning a second stdio server. Give the database-kind scenario its own claim and make stale-build failures legible. Extract two byte-identical fixture preambles that the max-lines rule had split across five and three files respectively, removing ~1,000 duplicated lines. Profile the heaviest files rather than only running them: the Apple projection test drops from 6.4s to 0.28s and the reference source import from 2.3s to 0.2s, with stronger assertions in both cases. * ci: keep native-authority suites out of the curated Windows lane CI showed the repointed curated list was red. Three of the restored files require verified Windows native authority, which windows-latest does not provide by design: - src/ghidra/GhidraProvider.test.ts, the replacement for the deleted tests/boundary/providers/ghidra/ghidraProvider.test.ts - tests/boundary/providers/ghidra/ghidraClient.test.ts - tests/boundary/process/providerProcess.test.ts All three fail with PrivateRuntimeRootUnavailableError, because Job Object process ownership and private-runtime DACL enforcement are unimplemented. The previous ghidraClient and providerProcess entries were added in this branch and never belonged here; the GhidraProvider replacement does not inherit the deleted file's Windows behaviour. Those claims belong behind the same approval boundary as the real Ghidra Windows workflow rather than the default lane. Record the exclusion in the guard test so the lane cannot regress to red, and in ci.yml so the next reader knows the omission is deliberate. The lane now runs 14 files, up from the 6 it silently ran before, all verified green on windows-latest. * test: stop max-lines from forcing test files to split The 500-line cap on test files only ever produced duplication. It chopped one binary session suite across nine files whose names were truncated mid-sentence, and it is why the curated Windows lane referenced six `processOwnership*` paths that had been split apart in the first place. Turn the cap off for tests and fixtures, keeping the function-length limit, then merge the nine binary session files back into one suite. That restores all 22 cases under a single module registry instead of nine and cuts the composition project from 20 files to 12. The merged cases become nine top-level describes grouped by concern, because a single wrapping describe would itself breach the function-length limit. Drop applicationWorkflowVerifier.test.ts. Its first case asserted `verified: true`, which the script emits as a literal after its own checks, so it could not fail, and every remaining assertion was `expect.any`. Its second case asserted an export-shape comparison that applicationWorkflowCli.test.ts already asserts through the product CLI, which is the stronger claim. Finally, five javascriptSemanticAnalysis files imported `it` from @fast-check/vitest while containing no properties. Import `it` from vitest so those files no longer read as property-based coverage. * test: stop creating temp scaffolding the temp helper immediately deletes `createTestTempDirectory` called `createTestWorkspace`, which creates `home` and `xdg/{config,cache}`, then deleted two of those three directories so the caller would receive an empty root. Every caller paid three `mkdir` calls and two `rm` calls per temporary directory for scaffolding it never used, across roughly a hundred test files. Create the directory directly instead. Callers that genuinely need a home or XDG tree can call `createTestWorkspace`. * test: merge the split process reactive runtime suite Five files shared one describe name in pairs and re-imported the same fixture helpers. With the shared preamble already extracted into processReactiveRuntime.fixture.ts, merge them into one suite of five top-level describes grouped by concern. All 12 cases are preserved and the domain project drops from 92 files to 88. * test: drop two identity round-trips from node runtime characterization One test built a plan with `max_calls: 10_001` and asserted the field came back as 10_001, which asserts that the plan constructor copies a field the test just set, and freezes a former ceiling in the process. The other built a 201-character alias and asserted it parsed back to the same string, which round-trips a value the test created. Neither could fail for a reason that matters. The remaining cases still cover preparation, stale-plan rejection, single dispatch, and artifact write. * test: hoist duplicated graph fixtures into the shared fixture module Eleven copies of three helpers were spread across the seven javascriptApplicationGraph files: `completeCoverage` five times, `nodeByLabel` four times, and `firstOf` twice, all byte-identical. The fixture module already carried a private `completeCoverage`. Export all three from javascriptApplicationGraph.fixture.ts and import them, removing 95 duplicated lines. * test: separate the reactive fixture's two PTY writes The multi-source run resolved target_lost instead of passed intermittently. The ready transition matches terminal text "Ready" and consumes that observation, advancing the terminal frontier; the collecting state then needs "Collecting" in a later observation. The fixture wrote both back to back, so the PTY sometimes coalesced them into one chunk and the collecting trigger could never match. Move the "Collecting" write after the synchronous shim probe. The probe takes real wall time, so the reader has drained the first chunk before the second is written, which makes the ordering deterministic rather than timing dependent. Measured 33/34 isolated passes, against roughly even odds before. Under four concurrent copies it still failed 2 of 12, so this narrows the window rather than closing it: the underlying race is that node-pty delivers buffered output through a callback the exit path never awaits, so the harness can declare the target lost before the last chunk is ingested. The test is left strict because it is correctly detecting that. * test: replace expect.any placeholders with real results Four acceptance scenarios and one composition scenario asserted only that a field was a string, number, object, or array. Those assertions cannot fail for a reason that matters, and three of them sit behind real CLI process spawns that cost about 1.5s. Capture the actual results once and assert them exactly. The CLI parity scenario now pins the trace summary and coverage, the version comparison summary and coverage, and the source-to-bundle summary and coverage, so each spawn earns its cost. The MCP rejection case now asserts `invalid_request` with category `invalid_input`, which is what makes it a caller-input failure rather than a provider one. The artifact comparison case asserted three `expect.any` values immediately before an assertion that subsumed them. Replace them with the claim that actually matters above 500 entries: manifest counts must reconcile with the arrays returned, and every written file must appear as an occurrence, so a reintroduced truncation would fail. * test: remove impossible and incidental prompt assertions Two assertions in toolResult could never fail: each asserted that the serialized result did not contain a string that appears nowhere in its own inputs, so only in the assertion itself. promptContracts asserted that a `Set` built from a locally declared six element literal had no duplicates, and pinned thirteen incidental fragments of rendered prompt prose. Drop the tautology and reduce the prose block to the five properties an analyst actually depends on: caller input framed as data rather than instruction, tools offered as options rather than a mandated sequence, the epistemic vocabulary present, tools never framed as authorization, and deterministic suggestion order. * test: hoist the remaining duplicated graph fixtures Eight more byte-identical helper copies across the javascriptApplicationGraph files: `graphForNode` four times, `unknownEvidence` twice, and `contentNode` twice. Move them into javascriptApplicationGraph.fixture.ts alongside the three hoisted earlier, and drop the imports they left behind. Net -92 lines across the seven files. * test: drop enhancedTools helpers already in the shared harness Three of the six enhancedTools files still carried local copies of PROCEDURES, inventory, and emptyBounded that are byte-identical to the exports added to enhancedToolsHarness.ts. Import them instead. Only fixturePort, connect, and jsonResult differ per file, so those stay local. * test: replace repeated process-tree event literals with fixture builders The file spelled out nineteen thirteen-field event literals and thirteen-field process nodes inline, varying only three or four fields each, so a test read as a wall of repeated boilerplate rather than the transitions under test. Add eventProcessTree.fixture.ts with spawn, exit, re-exec and reparent builders plus a process-node builder, and express each case as the sequence it describes. The test drops from 422 to 214 lines with all nine cases intact. * test: share the process-ownership validation fixtures The two process-ownership validation files each declared byte-identical copies of an `ownership` constant and a 24-line `host` builder. Move both into ProcessOwnership.fixture.ts and import them. * test: make rejection and retention assertions exact The reactive scenario rejection table asserted only that each of its six fixtures failed to parse. A typo anywhere in a fixture would satisfy that, including one that invalidated an unrelated field. Record the field each row is meant to invalidate and require the reported issue to point at that path with the expected code. Verified by making one row valid except for an unknown key: the row now fails instead of passing for the wrong reason. The large-body replay test asserted that the serialized result exceeded five million characters as a proxy for the bodies being retained, which escaping or padding could satisfy even under truncation. Compare each retained body to the input byte for byte and total the retained length. Verified by shaving one byte off the expected value, which now fails. * test: share the elicitation grants fixtures The three processCaptureElicitationGrants files each declared the same five helpers inline: an elicitation request, a fixed clock, a ceiling-only permission authority, a request-context builder, and a verified-state stub. Move them into elicitationGrantsHarness.ts and import them. * fix(process): wait for trailing PTY chunks before declaring target loss The exit path drained the reactive coordinator and then submitted target_lost, but node-pty delivers buffered output through onData callbacks that run outside the coordinator's queue. A trailing chunk that had not been delivered yet produced no journal entry, so the drain had nothing to wait for and the target was declared lost while a terminal trigger was still satisfiable. That is what made the multi-source reactive fixture resolve target_lost instead of passed at roughly even odds. Yield to the event loop until the capture journal stops growing before draining, bounded so a pathological stream cannot stall exit. The fixture change that accompanied this is no longer carrying the weight on its own; the ordering it introduced remains correct and worth keeping. Measured 18 consecutive passes across isolated and four-way concurrent runs, against roughly even odds beforehand. A control run with the wait disabled did not reproduce the failure, so the machine was not loaded enough for a controlled comparison; the before/after numbers are from separate sessions. * test: hoist the last duplicated domain and coordinator fixtures processTraceComparison and its Part2 declared the same eight helpers, and the two ProcessReactiveCoordinatorFailures files declared the same three. All were byte-identical. Move them into shared fixture modules and import them, netting 429 fewer lines. The earlier attempts at this failed because depth-based scanning mis-detected helper boundaries: bracket characters inside type annotations, such as `options: { truncated?: boolean }`, unbalance the count. Extracting by verified line range instead worked first time, and unused imports were then settled by alternating typecheck and lint rather than by guessing which names a body uses. * ci: ratchet the duplication gate to the reduced baseline Test-file duplication is down to 2.58% of lines across src and tests, from 3.39% when tests were first included in the gate. Lower the threshold from 3.2 to 2.7 so the reduction cannot regress. * test: merge the remaining artificially split test files Five suites were each split across two or three files by the max-lines rule that is no longer applied to tests, and every split produced a duplicate describe name and a duplicated import block. Merge them back: processTraceComparison, ProcessReactiveCoordinatorFailures, ProcessOwnership.validation, processCaptureElicitationGrants, and processCaptureElicitation. Net -62 lines and five fewer files. Give each merged describe a name describing what it covers, since the splits were arbitrary and the names now collide. Test bodies are unchanged; every describe already fitted the function-length limit, so merging them as separate top-level blocks satisfies it. Imports were settled by writing each block explicitly after typecheck and lint disagreed. Lint is right about names that appear only inside string literals, such as `values(["terminal", "process", "http", "websocket"])`, while typecheck is right about names a body genuinely references, so neither alone was sufficient. * test: merge the graph suite and share the enhancedTools result helper The seven javascriptApplicationGraph files were one suite with one describe name repeated seven times. Merge them into a single file with seven describes named for what each covers, keeping all 18 cases. Three of the enhancedTools files declared byte-identical copies of a jsonResult helper. Move it into enhancedToolsHarness.ts alongside the rest of the shared fixtures. The remaining enhancedTools files stay split: their fixturePort helpers are three genuinely different provider doubles rather than duplicates, so merging them would mean renaming per-file seams and lose the reason each exists. * ci: repoint the Windows lane at the merged ownership validation file Merging the split ProcessOwnership.validation files removed the two Part paths the curated Windows lane referenced. The curated-lane guard test caught it on CI, which is the failure it was added to prevent. * test(process): make lifecycle assertions falsifiable and scope them to POSIX Two assertions could not fail, so neither observed the behaviour it claimed. The deadline disposal test aborted an already-aborted controller and then read an immutable interruption, so it passed whether or not disposal released the timer or the listener. Dispose before aborting instead and assert the owned timer count reaches zero, which distinguishes a released deadline from a retained one. Verified by mutation: emptying dispose, dropping the listener removal, and dropping the timer clear each fail the test. The supervisor test asserted that a repeated stop still resolved, but stop caches its promise, so it proved nothing about detachment. Replace it with a late error and close event after disposal and assert the diagnostic channel stays untouched, keeping an attached control that proves the channel works. Verified by mutation: retaining the error listener fails the test. Also mark the TERM-to-KILL escalation POSIX-only. libuv maps every signal to TerminateProcess on Windows, where a child that ignores SIGTERM cannot survive the TERM step, so the escalation and SIGKILL signal code are unreachable there. * fix(vitest): restore cross-project scheduling for local runs Dropping the per-project `sequence.groupOrder` made every project run concurrently. `fileParallelism: false` only serialises files inside a single project, so `process-boundary` could still overlap with the boundary projects that observe host process and terminal state, which is the contention that drops input and resize echoes. The removed blanket per-project serialisation was slower, but it was never what isolated projects from each other. Keep the two-worker intra-project parallelism that made local runs fast and apply the ordering locally only, so CI keeps the concurrency it proves green. * fix(acceptance): make the database startup record check observable The check asserted stderr contained no fatal record, but a database-kind startup that only lists the catalog starts no bridge work and writes no records at all, so the assertion passed without observing anything. Reading stderr while the child was still running also raced the pipe, and splitting the buffer could parse a partial trailing line. Call a tool so startup records are actually produced, parse only complete newline-delimited records, and read after the transport closes so delivery is no longer in question. Guard the result with an explicit non-vacuity assertion so the fatal check cannot silently stop observing anything. Verified by mutation: injecting a level-60 record through the stderr logger now fails the test. * test(acceptance): restore the application workflow verifier coverage Nothing else in the repository executes scripts/verify-local-application-workflows.mjs, so deleting this file left the only automated check on a shipped script. Regressions in its dist imports, argument handling, or two-version and export-shape output could ship with a green suite. The two cases cost about 350ms and 500ms, so the duplication that motivated removing them was never the reason to drop the coverage. Verified by mutation: flipping the reported verification status fails the suite. * test(contracts): add a published tool name floor Every remaining inventory invariant is structural, so deleting a contract from both its group and the aggregate left the whole file green while the catalog regenerated without it. Structural checks cannot detect removal; only a declared baseline can. Declare the 114 published names as a floor rather than an equality. Adding a tool still needs no edit, while removing one now fails with a message naming the tool. Verified by mutation: dropping a contract from its group so the aggregate loses it too fails the floor. * test: restore no-ceiling coverage above plausible truncation limits Four suites claimed a result was complete at any scale while their own inputs were bounded below where a truncation would appear, so the guarantee they described was not actually under test. Scale independence cannot be asserted as a relationship over a fixture smaller than the cap being ruled out. Raise each fixture past any ceiling a regression would plausibly introduce, which is far cheaper than the removed cases: referenceSourceImport 600 entries/64 KiB -> 5,000 entries with a 4 MiB member customProtocolCapture generated max 40 frames -> 300..1,000 appleApplication 12 frameworks/16 candidates -> 250/1,600 nodeRuntimeCharacterization assert the absent call-budget and alias bounds where they would be added, since the budget derives from the case count Verified by mutation: a 1,000-entry import cap, a 500-frame capture cap, a 100-framework projection cap, a 100,000-call budget cap, and a 200-character alias cap each fail the corresponding suite. * fix(process): require a quiet period before declaring the target lost Returning on the first unchanged journal sample granted a single event-loop turn of grace. A chunk queued on a later iteration therefore still let `target_lost` be submitted before the final terminal observation, discarding a satisfiable reactive trigger under the same load-dependent exit scenario the drain was added for, and `maxTicks` never bounded that because the loop exited early every time. Require several consecutive unchanged turns so a callback arriving on any of the next few iterations still lands first, while keeping the overall tick bound so a pathological stream cannot stall exit. * fix: stop a 4 GiB fixture and measure the drain quiet period in time The reference import fixture cycled its five sizes across all 5,000 entries, which made a thousand 4 MiB members and nearly 4 GiB of fixture written concurrently. It happened to fit locally but cannot fit a runner's disk. Cross the two boundaries independently instead: entry count by volume of tiny members, byte size by a single 4 MiB member, for about 4.2 MiB total. Verified by mutation that a 1,000-entry cap and a 1 MiB per-member cap both still fail. The drain check counted scheduler turns, but consecutive setImmediate turns elapse in microseconds, so four of them finish long before a pty callback delayed under host contention, which is when the trailing chunk matters. Measure the quiet interval in elapsed time instead, so a late callback within the window still lands before target loss. Verified by simulation: the turn-count version missed any delay beyond a few microseconds, the timed version observes delays up to the quiet interval.
825 lines
28 KiB
TypeScript
825 lines
28 KiB
TypeScript
import type {
|
|
AnalysisClient,
|
|
AnalysisProvider,
|
|
} from "../../../src/application/AnalysisProvider.js";
|
|
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
|
import {
|
|
HopperStartError,
|
|
ProviderAdapterError,
|
|
} from "../../../src/domain/errors.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import { err, ok as resultOk } from "../../../src/domain/result.js";
|
|
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
|
import {
|
|
ControllableAnalysisClient,
|
|
createBinarySessionTargets,
|
|
createCacheProvider,
|
|
createDeferred,
|
|
createTestBinarySession,
|
|
} from "../../fixtures/binarySession.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const nonHopperProvider = (operations: string[]): AnalysisProvider => {
|
|
const provider: AnalysisProvider = {
|
|
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
|
|
capabilities: () => [
|
|
{
|
|
provider: { id: "fixture", name: "Fixture", version: "1" },
|
|
operation: "address_name",
|
|
available: true,
|
|
reason: null,
|
|
effects: {
|
|
mutatesArtifact: false,
|
|
launchesProcess: false,
|
|
mayShowUi: false,
|
|
mayAccessNetwork: false,
|
|
mayWriteFilesystem: false,
|
|
changesPermissions: false,
|
|
requiresRoot: false,
|
|
},
|
|
limitations: [],
|
|
},
|
|
],
|
|
createClient: (_target, _profile, context) => {
|
|
if (context === undefined)
|
|
throw new Error("missing analysis run context");
|
|
return {
|
|
execute: (operation) => {
|
|
operations.push(operation);
|
|
return Promise.resolve(ok(operation));
|
|
},
|
|
runtimeLineageSnapshots: () => [
|
|
{
|
|
provider: provider.identity(),
|
|
observation: {
|
|
status: "verified",
|
|
observedAt: "2026-07-22T10:00:00.000Z",
|
|
lineage: {
|
|
runId: context.runId,
|
|
launcherPid: 100,
|
|
launcherParentPid: 1,
|
|
processGroupId: 100,
|
|
descendants: [
|
|
{ pid: 101, parentPid: 100, processGroupId: 100 },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
requestActivitySnapshots: () => [
|
|
{
|
|
provider: provider.identity(),
|
|
active: {
|
|
requestId: 7,
|
|
operation: "analyze_function",
|
|
elapsedMs: 31_000,
|
|
callerState: "cancelled",
|
|
},
|
|
queuedRequests: 2,
|
|
},
|
|
],
|
|
close: () => Promise.resolve(),
|
|
};
|
|
},
|
|
};
|
|
return provider;
|
|
};
|
|
|
|
const client = (fail = false): AnalysisClient => ({
|
|
execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
|
|
close: () => Promise.resolve(),
|
|
});
|
|
|
|
describe("detached provider and target metadata", () => {
|
|
it("returns detached provider and target metadata", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const session = createTestBinarySession(createCacheProvider([]));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect(session.status()).toMatchObject({
|
|
analysis_run: {
|
|
run_id: expect.any(String),
|
|
process_lineage: { status: "not_observed" },
|
|
},
|
|
});
|
|
expect(session.listUnknowns()).toEqual([]);
|
|
const identity = session.providerIdentity();
|
|
Reflect.set(identity, "id", "forged");
|
|
expect(session.providerIdentity().id).toBe("fixture");
|
|
|
|
const active = session.activeTarget();
|
|
expect(active).toBeDefined();
|
|
if (active !== undefined) Reflect.set(active, "path", "/tmp/forged");
|
|
expect(session.activeTarget()?.path).toBe(first);
|
|
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("non-hopper provider dispatch", () => {
|
|
it("runs through a non-Hopper analysis provider", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const operations: string[] = [];
|
|
const provider = nonHopperProvider(operations);
|
|
const session = createTestBinarySession(provider);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect(await session.execute("address_name", {})).toEqual({
|
|
ok: true,
|
|
value: {
|
|
result: "address_name",
|
|
rawResult: "address_name",
|
|
provider: {
|
|
id: "fixture",
|
|
name: "Fixture analysis provider",
|
|
version: "1",
|
|
},
|
|
limitations: [],
|
|
locations: [],
|
|
subject: {
|
|
format: "analysis-database",
|
|
path: first,
|
|
sha256: expect.any(String),
|
|
},
|
|
},
|
|
});
|
|
expect(provider.identity().id).toBe("fixture");
|
|
expect(provider.capabilities()[0]?.operation).toBe("address_name");
|
|
expect(session.status()).toMatchObject({
|
|
provider: { id: "fixture", name: "Fixture", version: "1" },
|
|
providers: [{ id: "fixture", name: "Fixture", version: "1" }],
|
|
capabilities: [
|
|
{
|
|
operation: "address_name",
|
|
available: true,
|
|
reason: null,
|
|
effects: {
|
|
mutates_artifact: false,
|
|
launches_process: false,
|
|
},
|
|
},
|
|
],
|
|
analysis_run: {
|
|
run_id: expect.stringMatching(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
|
|
),
|
|
process_lineage: {
|
|
status: "snapshots",
|
|
snapshots: [
|
|
{
|
|
provider: { id: "fixture", name: "Fixture", version: "1" },
|
|
observation: {
|
|
status: "verified",
|
|
observed_at: "2026-07-22T10:00:00.000Z",
|
|
launcher_pid: 100,
|
|
launcher_parent_pid: 1,
|
|
process_group_id: 100,
|
|
descendants: [
|
|
{ pid: 101, parent_pid: 100, process_group_id: 100 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
analysis_activity: {
|
|
status: "busy",
|
|
providers: [
|
|
{
|
|
provider: { id: "fixture", name: "Fixture", version: "1" },
|
|
active: {
|
|
request_id: 7,
|
|
operation: "analyze_function",
|
|
elapsed_ms: 31_000,
|
|
caller_state: "cancelled",
|
|
},
|
|
queued_requests: 2,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(operations).toEqual(["health", "address_name"]);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("fresh run identity", () => {
|
|
it("allocates one fresh run identity per provider client lifetime", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
const createClient = provider.createClient.bind(provider);
|
|
const runIds: string[] = [];
|
|
provider.createClient = (target, profile, context) => {
|
|
if (context === undefined)
|
|
throw new Error("missing analysis run context");
|
|
runIds.push(context.runId);
|
|
return createClient(target, profile, context);
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.open(second)).ok).toBe(true);
|
|
|
|
expect(runIds).toHaveLength(2);
|
|
expect(new Set(runIds).size).toBe(2);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("replay of exact immutable calls", () => {
|
|
it("replays exact immutable calls from a matching provider-neutral snapshot", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const initialCalls: string[] = [];
|
|
const initial = createTestBinarySession(createCacheProvider(initialCalls));
|
|
expect((await initial.open(first)).ok).toBe(true);
|
|
expect(
|
|
(
|
|
await initial.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
const snapshot = initial.exportAnalysisSnapshot();
|
|
expect(snapshot.ok).toBe(true);
|
|
if (!snapshot.ok) return;
|
|
expect(snapshot.value.entries).toHaveLength(1);
|
|
await initial.close();
|
|
|
|
const replayCalls: string[] = [];
|
|
const replay = createTestBinarySession(createCacheProvider(replayCalls));
|
|
expect(replay.importAnalysisSnapshot(snapshot.value)).toEqual({
|
|
ok: true,
|
|
value: 1,
|
|
});
|
|
expect((await replay.open(first)).ok).toBe(true);
|
|
const cached = await replay.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
});
|
|
expect(cached.ok).toBe(true);
|
|
if (cached.ok)
|
|
expect(cached.value.limitations).toContainEqual(
|
|
expect.stringContaining("local REA analysis snapshot"),
|
|
);
|
|
expect(
|
|
(
|
|
await replay.execute("set_address_name", {
|
|
address: "0x1000",
|
|
name: "renamed",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(
|
|
(
|
|
await replay.execute("address_name", {
|
|
address: "0x1000",
|
|
document: "first",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
expect(replayCalls).toEqual(["health", "set_address_name", "address_name"]);
|
|
expect(replay.exportAnalysisSnapshot().ok).toBe(false);
|
|
expect((await replay.open(first)).ok).toBe(true);
|
|
expect(replay.exportAnalysisSnapshot()).toMatchObject({
|
|
ok: true,
|
|
value: { entries: [{ operation: "address_name" }] },
|
|
});
|
|
await replay.close();
|
|
|
|
const mismatch = createTestBinarySession(createCacheProvider([]));
|
|
expect(mismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
|
|
const opened = await mismatch.open(second);
|
|
expect(opened.ok).toBe(false);
|
|
if (!opened.ok) expect(opened.error._tag).toBe("EvidenceIntegrityError");
|
|
await mismatch.close();
|
|
|
|
const profileMismatchCalls: string[] = [];
|
|
const profileMismatchProvider = createCacheProvider(profileMismatchCalls);
|
|
const identity = profileMismatchProvider.identity();
|
|
profileMismatchProvider.resolveAnalysisProfile = () =>
|
|
Promise.resolve(
|
|
resultOk({
|
|
profile: createAnalysisProfile(
|
|
{
|
|
id: identity.id,
|
|
name: identity.name,
|
|
version: identity.version ?? "fixture-unresolved",
|
|
},
|
|
{ fixture: "different-profile" },
|
|
),
|
|
compatibility: {},
|
|
}),
|
|
);
|
|
const profileMismatch = createTestBinarySession(profileMismatchProvider);
|
|
expect(profileMismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(
|
|
true,
|
|
);
|
|
const profileOpened = await profileMismatch.open(first);
|
|
expect(profileOpened.ok).toBe(false);
|
|
if (!profileOpened.ok) {
|
|
expect(profileOpened.error._tag).toBe("EvidenceIntegrityError");
|
|
expect(profileOpened.error.message).toContain("profile_mismatch");
|
|
}
|
|
expect(profileMismatchCalls).toEqual([]);
|
|
await profileMismatch.close();
|
|
|
|
const activeProfileMismatch = createTestBinarySession(
|
|
profileMismatchProvider,
|
|
);
|
|
expect((await activeProfileMismatch.open(first)).ok).toBe(true);
|
|
const activeImport = activeProfileMismatch.importAnalysisSnapshot(
|
|
snapshot.value,
|
|
);
|
|
expect(activeImport.ok).toBe(false);
|
|
if (!activeImport.ok)
|
|
expect(activeImport.error.message).toContain("profile_mismatch");
|
|
await activeProfileMismatch.close();
|
|
});
|
|
|
|
it("does not snapshot reads that depend on the provider cursor", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.execute("address_name", {})).ok).toBe(true);
|
|
expect((await session.execute("address_name", {})).ok).toBe(true);
|
|
expect(calls).toEqual(["health", "address_name", "address_name"]);
|
|
expect(session.exportAnalysisSnapshot()).toMatchObject({
|
|
ok: true,
|
|
value: { entries: [] },
|
|
});
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("provider health and availability", () => {
|
|
it("publishes provider-health availability changes and resets on target switch", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
provider.createClient = () => ({
|
|
execute: (operation) =>
|
|
Promise.resolve(
|
|
operation === "health"
|
|
? ok(null)
|
|
: err(new ProviderAdapterError("fixture", operation)),
|
|
),
|
|
close: () => Promise.resolve(),
|
|
});
|
|
const session = createTestBinarySession(provider);
|
|
let changes = 0;
|
|
session.onAvailabilityChanged(() => {
|
|
changes += 1;
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.execute("address_name", {})).ok).toBe(false);
|
|
expect(session.status()).toMatchObject({
|
|
capabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operation: "address_name",
|
|
available: false,
|
|
reason:
|
|
"ProviderAdapterError: Provider fixture adapter failed during address_name",
|
|
}),
|
|
]),
|
|
});
|
|
expect(changes).toBe(1);
|
|
expect((await session.open(second)).ok).toBe(true);
|
|
expect(session.status()).toMatchObject({
|
|
capabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operation: "address_name",
|
|
available: true,
|
|
reason: null,
|
|
}),
|
|
]),
|
|
});
|
|
expect(changes).toBe(2);
|
|
});
|
|
|
|
it("isolates availability observers from execution results and session state", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
let providerCalls = 0;
|
|
provider.createClient = () => ({
|
|
execute: (operation) => {
|
|
if (operation === "health") return Promise.resolve(ok(null));
|
|
providerCalls += 1;
|
|
return Promise.resolve(
|
|
providerCalls === 1
|
|
? err(new ProviderAdapterError("fixture", operation))
|
|
: ok(operation),
|
|
);
|
|
},
|
|
close: () => Promise.resolve(),
|
|
});
|
|
const session = createTestBinarySession(provider);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const input = { address: "0x1000", document: "first" };
|
|
expect((await session.execute("address_name", input)).ok).toBe(false);
|
|
|
|
session.onAvailabilityChanged(() => {
|
|
throw new Error("external observer failed");
|
|
});
|
|
session.onAvailabilityChanged(() =>
|
|
Promise.reject(new Error("async external observer failed")),
|
|
);
|
|
let delivered = 0;
|
|
session.onAvailabilityChanged(() => {
|
|
delivered += 1;
|
|
});
|
|
|
|
await expect(session.execute("address_name", input)).resolves.toMatchObject(
|
|
{ ok: true },
|
|
);
|
|
expect(delivered).toBe(1);
|
|
expect(session.status()).toMatchObject({
|
|
capabilities: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operation: "address_name",
|
|
available: true,
|
|
reason: null,
|
|
}),
|
|
]),
|
|
});
|
|
expect((await session.execute("address_name", input)).ok).toBe(true);
|
|
expect(providerCalls).toBe(2);
|
|
});
|
|
|
|
it("does not replay operations with filesystem side effects", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const calls: string[] = [];
|
|
const session = createTestBinarySession(createCacheProvider(calls, true));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const input = { address: "0x1000", document: "first" };
|
|
expect((await session.execute("address_name", input)).ok).toBe(true);
|
|
expect((await session.execute("address_name", input)).ok).toBe(true);
|
|
expect(calls).toEqual(["health", "address_name", "address_name"]);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("typed unavailability without dispatching", () => {
|
|
it("returns typed unavailability without dispatching a partial provider", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const operations: string[] = [];
|
|
const provider: AnalysisProvider = {
|
|
identity: () => ({ id: "partial", name: "Partial", version: "1" }),
|
|
capabilities: () => [
|
|
{
|
|
provider: { id: "partial", name: "Partial", version: "1" },
|
|
operation: "address_name",
|
|
available: false,
|
|
reason: "fixture intentionally omits symbol lookup",
|
|
effects: {
|
|
mutatesArtifact: false,
|
|
launchesProcess: false,
|
|
mayShowUi: false,
|
|
mayAccessNetwork: false,
|
|
mayWriteFilesystem: false,
|
|
changesPermissions: false,
|
|
requiresRoot: false,
|
|
},
|
|
limitations: ["No symbol lookup implementation."],
|
|
},
|
|
],
|
|
createClient: () => ({
|
|
execute: (operation) => {
|
|
operations.push(operation);
|
|
return Promise.resolve(ok(operation));
|
|
},
|
|
close: () => Promise.resolve(),
|
|
}),
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const result = await session.execute("address_name", {});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
providerId: "partial",
|
|
operation: "address_name",
|
|
reason: "fixture intentionally omits symbol lookup",
|
|
});
|
|
}
|
|
expect(operations).toEqual(["health"]);
|
|
await session.close();
|
|
});
|
|
|
|
it("requires an open binary and closes idempotently", async () => {
|
|
const session = createTestBinarySession(() => client());
|
|
expect((await session.execute("binary_overview", {})).ok).toBe(false);
|
|
expect(await session.close()).toEqual({ ok: true, value: null });
|
|
});
|
|
|
|
it("keeps the active client when a switch fails", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
let created = 0;
|
|
const session = createTestBinarySession(() => client(created++ === 1));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.open(second)).ok).toBe(false);
|
|
expect(session.status()).toMatchObject({
|
|
open: true,
|
|
sha256:
|
|
"7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed",
|
|
architecture: null,
|
|
});
|
|
expect(JSON.stringify(session.status())).toContain("first.hop");
|
|
expect(created).toBe(3);
|
|
});
|
|
|
|
it("serializes concurrent opens and leaves the last target active", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const clients: ControllableAnalysisClient[] = [];
|
|
const session = createTestBinarySession(() => {
|
|
const value = new ControllableAnalysisClient();
|
|
clients.push(value);
|
|
return value;
|
|
});
|
|
const one = session.open(first);
|
|
const two = session.open(second);
|
|
expect((await one).ok).toBe(true);
|
|
expect((await two).ok).toBe(true);
|
|
expect(session.status()).toMatchObject({ open: true });
|
|
expect(JSON.stringify(session.status())).toContain("second.hop");
|
|
expect(clients[0]?.closed).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("active client replacement", () => {
|
|
it("replaces the active client when a canonical path changes contents", async () => {
|
|
const directory = await createTestTempDirectory("bb-session-");
|
|
const path = join(directory, "mutable.hop");
|
|
await writeFile(path, "one");
|
|
const clients: Array<{
|
|
readonly targetSha256: string;
|
|
readonly calls: string[];
|
|
closed: number;
|
|
}> = [];
|
|
const session = createTestBinarySession((target) => {
|
|
const state = {
|
|
targetSha256: target.sha256,
|
|
calls: [] as string[],
|
|
closed: 0,
|
|
};
|
|
clients.push(state);
|
|
return {
|
|
execute: (operation) => {
|
|
state.calls.push(operation);
|
|
return Promise.resolve(
|
|
ok(operation === "health" ? null : target.sha256),
|
|
);
|
|
},
|
|
close: () => {
|
|
state.closed += 1;
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
});
|
|
|
|
const first = await session.open(path);
|
|
expect(first.ok).toBe(true);
|
|
await writeFile(path, "two");
|
|
const second = await session.open(path);
|
|
expect(second.ok).toBe(true);
|
|
if (!first.ok || !second.ok) return;
|
|
|
|
expect(second.value.sha256).not.toBe(first.value.sha256);
|
|
expect(session.activeTarget()?.sha256).toBe(second.value.sha256);
|
|
expect(await session.execute("binary_overview", {})).toMatchObject({
|
|
ok: true,
|
|
value: { result: second.value.sha256 },
|
|
});
|
|
expect(clients).toMatchObject([
|
|
{
|
|
targetSha256: first.value.sha256,
|
|
calls: ["health"],
|
|
closed: 1,
|
|
},
|
|
{
|
|
targetSha256: second.value.sha256,
|
|
calls: ["health", "binary_overview"],
|
|
closed: 0,
|
|
},
|
|
]);
|
|
await session.close();
|
|
});
|
|
|
|
it("waits for an active call before closing its client during a switch", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const active = createDeferred<ReturnType<typeof ok>>();
|
|
const clients: ControllableAnalysisClient[] = [];
|
|
const session = createTestBinarySession(() => {
|
|
const value = new ControllableAnalysisClient(
|
|
undefined,
|
|
false,
|
|
clients.length === 0 ? active.promise : undefined,
|
|
);
|
|
clients.push(value);
|
|
return value;
|
|
});
|
|
await session.open(first);
|
|
const call = session.execute("procedure_pseudo_code", {});
|
|
const switching = session.open(second);
|
|
await Promise.resolve();
|
|
expect(clients[0]?.closed).toBe(0);
|
|
active.resolve(ok(null));
|
|
await call;
|
|
await switching;
|
|
expect(clients[0]?.closed).toBe(1);
|
|
});
|
|
|
|
it("cancels an open queued behind another transition without creating a client", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const health = createDeferred<ReturnType<typeof ok>>();
|
|
let created = 0;
|
|
const session = createTestBinarySession(() => {
|
|
created += 1;
|
|
return new ControllableAnalysisClient(
|
|
created === 1 ? health.promise : undefined,
|
|
);
|
|
});
|
|
const opening = session.open(first);
|
|
const controller = new AbortController();
|
|
const queued = session.open(second, { signal: controller.signal });
|
|
controller.abort();
|
|
health.resolve(ok(null));
|
|
await opening;
|
|
const result = await queued;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(created).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("cancellation during profile resolution", () => {
|
|
it("owns provider rejection when profile resolution synchronously cancels opening", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
const controller = new AbortController();
|
|
provider.resolveAnalysisProfile = () => {
|
|
controller.abort();
|
|
return Promise.reject(new Error("Profile resolution cancelled"));
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
try {
|
|
await expect(
|
|
session.open(first, { signal: controller.signal }),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
|
});
|
|
// Allow Node to report any rejection abandoned by the cancellation path.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it("cancels legacy profile resolution even when the provider ignores its signal", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const provider = createCacheProvider([]);
|
|
let observedSignal: AbortSignal | undefined;
|
|
let created = 0;
|
|
provider.resolveAnalysisProfile = (_target, options) => {
|
|
observedSignal = options?.signal;
|
|
return new Promise<never>(() => undefined);
|
|
};
|
|
provider.createClient = () => {
|
|
created += 1;
|
|
return new ControllableAnalysisClient();
|
|
};
|
|
const session = createTestBinarySession(provider);
|
|
const controller = new AbortController();
|
|
const opening = session.open(first, { signal: controller.signal });
|
|
while (observedSignal === undefined)
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(observedSignal).toBe(controller.signal);
|
|
|
|
controller.abort();
|
|
|
|
await expect(opening).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
|
});
|
|
expect(created).toBe(0);
|
|
});
|
|
|
|
it("cancels a call while it waits for a transition", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const health = createDeferred<ReturnType<typeof ok>>();
|
|
const session = createTestBinarySession(
|
|
() => new ControllableAnalysisClient(health.promise),
|
|
);
|
|
const opening = session.open(first);
|
|
const controller = new AbortController();
|
|
const call = session.execute(
|
|
"binary_overview",
|
|
{},
|
|
{
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
controller.abort();
|
|
const result = await call;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
health.resolve(ok(null));
|
|
await opening;
|
|
});
|
|
|
|
it("closes a failed candidate and reopens the previous target", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const clients: ControllableAnalysisClient[] = [];
|
|
const session = createTestBinarySession(() => {
|
|
const value = new ControllableAnalysisClient(
|
|
undefined,
|
|
clients.length === 1,
|
|
);
|
|
clients.push(value);
|
|
return value;
|
|
});
|
|
await session.open(first);
|
|
await session.open(second);
|
|
expect(clients[1]?.closed).toBe(1);
|
|
expect(clients[0]?.closed).toBe(1);
|
|
expect(clients[2]?.closed).toBe(0);
|
|
expect(JSON.stringify(session.status())).toContain("first.hop");
|
|
await session.close();
|
|
await session.close();
|
|
expect(clients[2]?.closed).toBe(1);
|
|
});
|
|
|
|
it("closes the active bridge before starting a replacement", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
let liveClients = 0;
|
|
let overlapped = false;
|
|
const session = createTestBinarySession(() => ({
|
|
execute: () => {
|
|
if (liveClients > 0) overlapped = true;
|
|
liveClients += 1;
|
|
return Promise.resolve(ok(null));
|
|
},
|
|
close: () => {
|
|
liveClients -= 1;
|
|
return Promise.resolve();
|
|
},
|
|
}));
|
|
await session.open(first);
|
|
await session.open(second);
|
|
expect(overlapped).toBe(false);
|
|
await session.close();
|
|
});
|
|
|
|
it("clears session state while preserving a typed provider cleanup failure", async () => {
|
|
const [first] = await createBinarySessionTargets();
|
|
const cleanupError = new ProviderCleanupError(
|
|
"fixture",
|
|
["fixture-document"],
|
|
{ reason: "shutdown acknowledgement missing" },
|
|
);
|
|
const session = createTestBinarySession(() => ({
|
|
execute: () => Promise.resolve(ok(null)),
|
|
closeWithOutcome: () => Promise.resolve(err(cleanupError)),
|
|
close: () => Promise.resolve(),
|
|
}));
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
|
|
expect(await session.close()).toEqual(err(cleanupError));
|
|
expect(session.status()).toMatchObject({
|
|
open: false,
|
|
analysis_activity: { status: "not_observed", providers: [] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("no replacement start", () => {
|
|
it("does not start a replacement after the active provider cleanup is unconfirmed", async () => {
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const cleanupError = new ProviderCleanupError(
|
|
"fixture",
|
|
["fixture-document"],
|
|
{ reason: "shutdown acknowledgement missing" },
|
|
);
|
|
let created = 0;
|
|
const session = createTestBinarySession(() => {
|
|
created += 1;
|
|
return {
|
|
execute: () => Promise.resolve(ok(null)),
|
|
closeWithOutcome: () => Promise.resolve(err(cleanupError)),
|
|
close: () => Promise.resolve(),
|
|
};
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
|
|
expect(await session.open(second)).toEqual(err(cleanupError));
|
|
expect(created).toBe(1);
|
|
expect(session.status()).toMatchObject({ open: false });
|
|
});
|
|
});
|