carthagent 1.0.7 → 1.1.0-preview.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +177 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +11 -1
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +8 -4
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +211 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +125 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +129 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +60 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +11 -0
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +53 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +451 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +395 -0
- package/tests/native-voyages-ui.test.mjs +277 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +546 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +230 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { CloudClient } from '../lib/cloud/client.mjs';
|
|
5
|
+
import { mkdtempSync } from 'node:fs';
|
|
6
|
+
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { JsonSchema, Schema, SchemaRepresentation } from 'effect';
|
|
12
|
+
const testRoot = mkdtempSync(join(tmpdir(), 'ctg-voyages-rpc-'));
|
|
13
|
+
after(() => rm(testRoot, { recursive: true, force: true }));
|
|
14
|
+
const { createJiti } = createRequire(import.meta.url)('jiti');
|
|
15
|
+
const jiti = createJiti(import.meta.url);
|
|
16
|
+
const { createVoyagesRpcHandlers } = await jiti.import('../plugins/carthagent/src/server/voyages-rpc.ts');
|
|
17
|
+
const { Voyages } = await jiti.import('../plugins/carthagent/src/voyages-rpc.ts');
|
|
18
|
+
const NOW = 1800000000000;
|
|
19
|
+
const profile = {
|
|
20
|
+
version: 1, name: 'Voyager', shipName: 'Maiden', coins: 10, totalXp: 0, level: 1, xpIntoLevel: 0, xpForLevel: 500, km: 0, weeklyKm: 0,
|
|
21
|
+
config: { hairStyle: 'cropped', hat: 'beanie', shirt: 'none', bottoms: 'underwear', shoes: 'barefoot', pet: 'none', hull: 'cedar', sail: 'gold', setting: 'dusk', skin: 'warm', hair: 'dark', pattern: 'stripes' }, owned: ['hat:beanie'],
|
|
22
|
+
};
|
|
23
|
+
const catalog = { item: 'hat:captain', name: 'Captain', slot: 'hat', itemKey: 'captain', price: 5, requiresPurchase: true, available: true, eligible: true, reason: null };
|
|
24
|
+
const authoritativeQuote = { id: 'quote-1', item: catalog.item, price: 5, expiresAt: new Date(NOW + 120000).toISOString() };
|
|
25
|
+
const receipt = { receiptId: 'receipt-1', item: catalog.item, price: 5 };
|
|
26
|
+
const call = { signal: new AbortController().signal, error(type, message, data) { throw Object.assign(new Error(message), { type, data }); } };
|
|
27
|
+
function setup() {
|
|
28
|
+
const profileRoot = mkdtempSync(join(testRoot, 'profile-'));
|
|
29
|
+
const data = new Map(), requests = [], writes = [];
|
|
30
|
+
let id = 'account-a';
|
|
31
|
+
const ctx = {
|
|
32
|
+
storage: { get: async k => structuredClone(data.get(k)), set: async (k, v) => { writes.push(structuredClone(v)); data.set(k, structuredClone(v)); } },
|
|
33
|
+
integration: { connection: {
|
|
34
|
+
active: async integration => { assert.equal(integration, 'experiential-labs'); return id ? { id, type: 'credential', method: 'oauth' } : undefined; },
|
|
35
|
+
resolve: async () => ({ type: 'oauth', methodID: 'carthagent-device', access: 'secret-fixture' }),
|
|
36
|
+
} },
|
|
37
|
+
};
|
|
38
|
+
const cloud = { request: async (path, input) => {
|
|
39
|
+
requests.push({ path, ...input });
|
|
40
|
+
if (path.endsWith('/bootstrap')) return { signedIn: true, profile: structuredClone(profile) };
|
|
41
|
+
if (path.endsWith('/shop')) return { items: [structuredClone(catalog)] };
|
|
42
|
+
if (path.endsWith('/quotes')) return { quote: structuredClone(authoritativeQuote) };
|
|
43
|
+
if (path.endsWith('/purchases')) { assert.ok([...data.values()].some(s => s.pending?.key === input.body.key)); return receipt; }
|
|
44
|
+
return { profile: structuredClone(profile) };
|
|
45
|
+
} };
|
|
46
|
+
const journal = { get: file => ctx.storage.get(file), set: (file, state) => ctx.storage.set(file, state) };
|
|
47
|
+
const make = (baseUrl = 'https://api.example.test') => createVoyagesRpcHandlers(ctx, { cloudClient: cloud, baseUrl, now: () => NOW, profileRoot, journal });
|
|
48
|
+
return { ctx, cloud, data, requests, writes, make, profileRoot, handlers: make(), switchAccount: value => { id = value; } };
|
|
49
|
+
}
|
|
50
|
+
const rejected = (promise, code, uncertain = false) => assert.rejects(promise, error => error.type === 'voyagesError' && error.message === code && error.data.code === code && error.data.uncertain === uncertain);
|
|
51
|
+
const getQuote = handlers => handlers.quote({ slot: 'hat', item: 'captain' }, call);
|
|
52
|
+
|
|
53
|
+
test('Voyages is a separate six-method typed error contract', () => {
|
|
54
|
+
assert.equal(Voyages.id, 'carthagent.voyages');
|
|
55
|
+
assert.deepEqual(Object.keys(Voyages.methods), ['snapshot', 'customize', 'equip', 'quote', 'purchase', 'retryPurchase']);
|
|
56
|
+
for (const method of Object.values(Voyages.methods)) assert.ok(method.errors.voyagesError);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('pinned Effect native conversion accepts every RPC input, output and typed error', () => {
|
|
60
|
+
const samples = {
|
|
61
|
+
snapshot: [{}, { profile, items: [catalog, { ...catalog, item: 'hat:beanie', itemKey: 'beanie', price: null }], pending: { quoteId: 'quote-1', item: catalog.item, price: 5 } }],
|
|
62
|
+
customize: [{ version: 1, name: 'New', skin: 'warm', hair: 'dark', pattern: 'plain' }, { profile }],
|
|
63
|
+
equip: [{ version: 1, slot: 'hat', item: 'beanie' }, { profile }],
|
|
64
|
+
quote: [{ slot: 'hat', item: 'captain' }, { quote: authoritativeQuote }],
|
|
65
|
+
purchase: [{ quoteId: 'quote-1' }, receipt],
|
|
66
|
+
retryPurchase: [{ quoteId: 'quote-1' }, receipt],
|
|
67
|
+
};
|
|
68
|
+
const decode = (schema, value) => Schema.decodeUnknownSync(SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)))(value);
|
|
69
|
+
// Prove this exercises the failure mode, not a permissive substitute validator.
|
|
70
|
+
assert.throws(() => decode({ type: 'string', pattern: '^[a-z]+$' }, 'beanie'));
|
|
71
|
+
for (const [name, method] of Object.entries(Voyages.methods)) {
|
|
72
|
+
assert.deepEqual(decode(method.input, samples[name][0]), samples[name][0], `${name} input`);
|
|
73
|
+
assert.deepEqual(decode(method.output, samples[name][1]), samples[name][1], `${name} output`);
|
|
74
|
+
for (const schema of Object.values(method.errors)) {
|
|
75
|
+
const error = { code: 'quote_expired', status: 409, uncertain: false };
|
|
76
|
+
assert.deepEqual(decode(schema, error), error, `${name} error`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
assert.throws(() => decode(Voyages.methods.retryPurchase.input, {}));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('manual key checks reject invalid equip and quote selections before network traffic', async () => {
|
|
83
|
+
for (const method of ['equip', 'quote']) {
|
|
84
|
+
const s = setup();
|
|
85
|
+
await rejected(s.handlers[method]({ version: 1, slot: 'hat', item: '../unsafe' }, call), 'invalid_response');
|
|
86
|
+
assert.equal(s.requests.length, 0);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('confirmed retry identity mismatch never sends, and only proven expired replay clears pending', async () => {
|
|
91
|
+
const s = setup(), original = s.cloud.request;
|
|
92
|
+
await getQuote(s.handlers);
|
|
93
|
+
s.cloud.request = async () => { throw new Error('unknown'); };
|
|
94
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, call), 'request_failed', true);
|
|
95
|
+
const pending = structuredClone([...s.data.values()][0].pending);
|
|
96
|
+
for (const input of [{}, { quoteId: 'different-quote' }]) {
|
|
97
|
+
s.cloud.request = async () => assert.fail('mismatched confirmation must not send');
|
|
98
|
+
await rejected(s.handlers.retryPurchase(input, call), 'pending_changed', true);
|
|
99
|
+
assert.deepEqual([...s.data.values()][0].pending, pending);
|
|
100
|
+
}
|
|
101
|
+
for (const [code, status] of [['invalid_token', 401], ['insufficient_scope', 403], ['quote_consumed', 409], ['key_conflict', 409]]) {
|
|
102
|
+
s.cloud.request = async () => { throw Object.assign(new Error('private'), { code, status }); };
|
|
103
|
+
await rejected(s.handlers.retryPurchase({ quoteId: 'quote-1' }, call), code, true);
|
|
104
|
+
assert.deepEqual([...s.data.values()][0].pending, pending);
|
|
105
|
+
}
|
|
106
|
+
s.cloud.request = async () => { throw Object.assign(new Error('expired'), { code: 'quote_expired', status: 409 }); };
|
|
107
|
+
await rejected(s.handlers.retryPurchase({ quoteId: 'quote-1' }, call), 'quote_expired');
|
|
108
|
+
assert.equal([...s.data.values()][0].pending, undefined);
|
|
109
|
+
s.cloud.request = original;
|
|
110
|
+
assert.deepEqual(await getQuote(s.handlers), { quote: authoritativeQuote });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('snapshot validates authoritative DTOs, strips extras and uses native credentials', async () => {
|
|
114
|
+
const s = setup();
|
|
115
|
+
assert.deepEqual(await s.handlers.snapshot({}, call), { profile, items: [catalog] });
|
|
116
|
+
assert.deepEqual(s.requests.map(r => r.path), ['/v1/voyages/bootstrap', '/v1/voyages/shop']);
|
|
117
|
+
for (const request of s.requests) { assert.equal(request.accessToken, 'secret-fixture'); assert.equal(request.signal, call.signal); }
|
|
118
|
+
assert.equal(JSON.stringify(await s.handlers.snapshot({}, call)).includes('secret-fixture'), false);
|
|
119
|
+
s.ctx.integration.connection.resolve = async () => ({ type: 'oauth', methodID: 'other', access: 'secret-fixture' });
|
|
120
|
+
await rejected(s.handlers.snapshot({}, call), 'authentication_required');
|
|
121
|
+
s.switchAccount(undefined);
|
|
122
|
+
await rejected(s.handlers.snapshot({}, call), 'authentication_required');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('malformed profile, numeric/config fields and shop metadata fail closed', async () => {
|
|
126
|
+
for (const change of [p => { p.coins = '10'; }, p => { p.config.skin = 'bogus'; }, p => { p.config.hat = {}; }, p => { p.weeklyKm = Infinity; }, p => { p.version = 0; }, p => { p.owned = ['hat:beanie:extra']; }]) {
|
|
127
|
+
const s = setup(), p = structuredClone(profile); change(p);
|
|
128
|
+
s.cloud.request = async () => ({ signedIn: true, profile: p });
|
|
129
|
+
await rejected(s.handlers.snapshot({}, call), 'invalid_response');
|
|
130
|
+
}
|
|
131
|
+
const s = setup(), original = s.cloud.request;
|
|
132
|
+
s.cloud.request = async (path, input) => path.endsWith('/shop') ? { items: [{ ...catalog, requiresPurchase: undefined }] } : original(path, input);
|
|
133
|
+
await rejected(s.handlers.snapshot({}, call), 'invalid_response');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('owned retired paid catalog price stays null, while unowned null and null quote prices fail closed', async () => {
|
|
137
|
+
const s = setup(), original = s.cloud.request;
|
|
138
|
+
const retired = { ...catalog, item: 'hat:beanie', itemKey: 'beanie', name: 'Beanie', price: null, available: false, reason: 'retired' };
|
|
139
|
+
s.cloud.request = async (path, input) => path.endsWith('/shop') ? { items: [retired] } : original(path, input);
|
|
140
|
+
const snapshot = await s.handlers.snapshot({}, call);
|
|
141
|
+
assert.deepEqual(snapshot.items, [retired]);
|
|
142
|
+
assert.equal(snapshot.items[0].price, null);
|
|
143
|
+
assert.equal(snapshot.items[0].requiresPurchase, true);
|
|
144
|
+
s.cloud.request = async (path, input) => path.endsWith('/shop') ? { items: [{ ...catalog, price: null }] } : original(path, input);
|
|
145
|
+
await rejected(s.handlers.snapshot({}, call), 'invalid_response');
|
|
146
|
+
s.cloud.request = async () => ({ quote: { ...authoritativeQuote, price: null } });
|
|
147
|
+
await rejected(getQuote(s.handlers), 'invalid_response');
|
|
148
|
+
assert.equal(s.data.size, 0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('exact mutation endpoints forward versions and cancellation without retries', async () => {
|
|
152
|
+
const s = setup();
|
|
153
|
+
await s.handlers.customize({ version: 1, name: 'New' }, call);
|
|
154
|
+
await s.handlers.equip({ version: 1, slot: 'hat', item: 'beanie' }, call);
|
|
155
|
+
assert.deepEqual(s.requests.map(r => [r.path, r.method, r.body]), [
|
|
156
|
+
['/v1/voyages/profile', 'PATCH', { version: 1, name: 'New' }],
|
|
157
|
+
['/v1/voyages/equip', 'POST', { version: 1, slot: 'hat', item: 'beanie' }],
|
|
158
|
+
]);
|
|
159
|
+
s.cloud.request = async () => { throw Object.assign(new Error('secret response detail'), { code: 'version_conflict', status: 409, detail: 'secret' }); };
|
|
160
|
+
await rejected(s.handlers.customize({ version: 1, name: 'New' }, call), 'version_conflict');
|
|
161
|
+
const controller = new AbortController(); controller.abort(new Error('secret abort reason'));
|
|
162
|
+
await rejected(s.handlers.equip({}, { ...call, signal: controller.signal }), 'request_failed');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('stored authoritative quote is required, TTL bounded and price never comes from UI', async () => {
|
|
166
|
+
const s = setup();
|
|
167
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, call), 'quote_required');
|
|
168
|
+
assert.deepEqual(await getQuote(s.handlers), { quote: authoritativeQuote });
|
|
169
|
+
await s.handlers.purchase({ quoteId: 'quote-1', price: 0 }, call);
|
|
170
|
+
const request = s.requests.find(r => r.path.endsWith('/purchases'));
|
|
171
|
+
assert.deepEqual(Object.keys(request.body).sort(), ['key', 'quoteId']);
|
|
172
|
+
assert.match(request.body.key, /^[\da-f-]{36}$/);
|
|
173
|
+
assert.deepEqual([...s.data.values()][0], { quotes: [] });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('persistence failure prevents send; unknown result survives restart and replays exact pair', async () => {
|
|
177
|
+
const s = setup(); await getQuote(s.handlers);
|
|
178
|
+
const save = s.ctx.storage.set;
|
|
179
|
+
s.ctx.storage.set = async () => { throw new Error('disk secret'); };
|
|
180
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, call), 'request_failed');
|
|
181
|
+
assert.equal(s.requests.some(r => r.path.endsWith('/purchases')), false);
|
|
182
|
+
s.ctx.storage.set = save;
|
|
183
|
+
const original = s.cloud.request;
|
|
184
|
+
s.cloud.request = async (path, input) => { const result = await original(path, input); if (path.endsWith('/purchases')) throw new Error('connection lost'); return result; };
|
|
185
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, call), 'request_failed', true);
|
|
186
|
+
const pair = s.requests.at(-1).body;
|
|
187
|
+
const restarted = s.make();
|
|
188
|
+
assert.deepEqual((await restarted.snapshot({}, call)).pending, { quoteId: 'quote-1', item: catalog.item, price: 5 });
|
|
189
|
+
await rejected(getQuote(restarted), 'purchase_pending', true);
|
|
190
|
+
await rejected(restarted.purchase({ quoteId: 'quote-1' }, call), 'purchase_pending', true);
|
|
191
|
+
s.cloud.request = original;
|
|
192
|
+
assert.deepEqual(await restarted.retryPurchase({ quoteId: 'quote-1' }, call), receipt);
|
|
193
|
+
assert.deepEqual(s.requests.at(-1).body, pair);
|
|
194
|
+
await rejected(restarted.retryPurchase({ quoteId: 'quote-1' }, call), 'no_pending_purchase');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('concurrent purchases serialize and never replace a pending request key', async () => {
|
|
198
|
+
const s = setup(); await getQuote(s.handlers);
|
|
199
|
+
const original = s.cloud.request;
|
|
200
|
+
s.cloud.request = async (path, input) => { await original(path, input); throw new Error('unknown'); };
|
|
201
|
+
const results = await Promise.allSettled([s.handlers.purchase({ quoteId: 'quote-1' }, call), s.make().purchase({ quoteId: 'quote-1' }, call)]);
|
|
202
|
+
assert.equal(results.every(r => r.status === 'rejected'), true);
|
|
203
|
+
assert.equal(s.requests.filter(r => r.path.endsWith('/purchases')).length, 1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('account and API origin isolate pending; account switch during request is fail closed', async () => {
|
|
207
|
+
const s = setup(); await getQuote(s.handlers);
|
|
208
|
+
const original = s.cloud.request;
|
|
209
|
+
s.cloud.request = async (path, input) => { const result = await original(path, input); if (path.endsWith('/purchases')) s.switchAccount('account-b'); return result; };
|
|
210
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, call), 'account_changed', true);
|
|
211
|
+
assert.equal((await s.handlers.snapshot({}, call)).pending, undefined);
|
|
212
|
+
await rejected(s.handlers.retryPurchase({ quoteId: 'quote-1' }, call), 'no_pending_purchase');
|
|
213
|
+
s.switchAccount('account-a');
|
|
214
|
+
assert.ok((await s.handlers.snapshot({}, call)).pending);
|
|
215
|
+
assert.equal((await s.make('https://other.example.test').snapshot({}, call)).pending, undefined);
|
|
216
|
+
await rejected(s.make('https://other.example.test').retryPurchase({ quoteId: 'quote-1' }, call), 'no_pending_purchase');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('terminal no-debit rejection clears first attempt, but conflicts and replay errors retain pending', async () => {
|
|
220
|
+
for (const code of ['insufficient_coins', 'terms_changed', 'key_conflict', 'quote_consumed', 'invalid_token', 'insufficient_scope']) {
|
|
221
|
+
const s = setup(); await getQuote(s.handlers);
|
|
222
|
+
s.cloud.request = async () => { throw Object.assign(new Error('secret'), { code, status: code === 'invalid_token' ? 401 : code === 'insufficient_scope' ? 403 : 409 }); };
|
|
223
|
+
const uncertain = ['key_conflict', 'quote_consumed'].includes(code);
|
|
224
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, call), code, uncertain);
|
|
225
|
+
assert.equal(!![...s.data.values()][0].pending, uncertain);
|
|
226
|
+
if (uncertain) {
|
|
227
|
+
s.cloud.request = async () => { throw Object.assign(new Error('secret'), { code: 'quote_expired', status: 409 }); };
|
|
228
|
+
await rejected(s.handlers.retryPurchase({ quoteId: 'quote-1' }, call), 'quote_expired');
|
|
229
|
+
assert.equal([...s.data.values()][0].pending, undefined);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('only bounded UTC ISO quote expiry is accepted, never numeric or non-UTC', async () => {
|
|
235
|
+
for (const q of [
|
|
236
|
+
...[(NOW + 10000) / 1000, NOW + 10000, new Date(NOW - 1000).toISOString(), new Date(NOW + 999999).toISOString(), '2027-01-15T08:02:00+00:00', '2027-02-30T08:02:00Z'].map(expiresAt => ({ ...authoritativeQuote, expiresAt })),
|
|
237
|
+
{ ...authoritativeQuote, item: 'hat:other' },
|
|
238
|
+
]) {
|
|
239
|
+
const s = setup(); s.cloud.request = async () => ({ quote: q });
|
|
240
|
+
await rejected(getQuote(s.handlers), 'invalid_response');
|
|
241
|
+
assert.equal(s.data.size, 0);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('in-flight cancellation retains persisted purchase pair and passes the original signal', async () => {
|
|
246
|
+
const s = setup(); await getQuote(s.handlers);
|
|
247
|
+
const controller = new AbortController(), original = s.cloud.request;
|
|
248
|
+
s.cloud.request = async (path, input) => {
|
|
249
|
+
assert.equal(input.signal, controller.signal);
|
|
250
|
+
await original(path, input);
|
|
251
|
+
controller.abort(new Error('private cancellation reason'));
|
|
252
|
+
input.signal.throwIfAborted();
|
|
253
|
+
};
|
|
254
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, { ...call, signal: controller.signal }), 'request_failed', true);
|
|
255
|
+
assert.ok([...s.data.values()][0].pending);
|
|
256
|
+
const pair = s.requests.at(-1).body;
|
|
257
|
+
s.cloud.request = original;
|
|
258
|
+
await s.make().retryPurchase({ quoteId: 'quote-1' }, call);
|
|
259
|
+
assert.deepEqual(s.requests.at(-1).body, pair);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('receipt validation and clearing storage failures preserve exact pending replay', async () => {
|
|
263
|
+
for (const invalid of [true, false]) {
|
|
264
|
+
const s = setup(); await getQuote(s.handlers);
|
|
265
|
+
const original = s.cloud.request, save = s.ctx.storage.set;
|
|
266
|
+
s.cloud.request = async (path, input) => {
|
|
267
|
+
const result = await original(path, input);
|
|
268
|
+
if (!path.endsWith('/purchases')) return result;
|
|
269
|
+
if (invalid) return { ...receipt, price: 0 };
|
|
270
|
+
s.ctx.storage.set = async () => { throw new Error('disk error'); };
|
|
271
|
+
return result;
|
|
272
|
+
};
|
|
273
|
+
await rejected(s.handlers.purchase({ quoteId: 'quote-1' }, call), invalid ? 'invalid_response' : 'request_failed', true);
|
|
274
|
+
assert.ok([...s.data.values()][0].pending);
|
|
275
|
+
s.ctx.storage.set = save;
|
|
276
|
+
s.cloud.request = original;
|
|
277
|
+
assert.deepEqual(await s.make().retryPurchase({ quoteId: 'quote-1' }, call), receipt);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('unknown backend codes and details are never reflected; versioned unknown writes are uncertain', async () => {
|
|
282
|
+
const s = setup();
|
|
283
|
+
s.cloud.request = async () => { throw Object.assign(new Error('private-token'), { code: 'private-token', detail: 'private-token', status: 503 }); };
|
|
284
|
+
await rejected(s.handlers.snapshot({}, call), 'request_rejected');
|
|
285
|
+
await rejected(s.handlers.customize({ version: 1, name: 'New' }, call), 'request_rejected', true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('real CloudClient uses exact bearer endpoints without cookies, CSRF or fabricated origin', async () => {
|
|
289
|
+
const s = setup(), wire = [];
|
|
290
|
+
const cloud = new CloudClient({ baseUrl: 'https://api.example.test', fetchImpl: async (url, init) => {
|
|
291
|
+
wire.push({ url, ...init });
|
|
292
|
+
return { ok: true, text: async () => JSON.stringify(url.endsWith('/bootstrap') ? { signedIn: true, profile } : { items: [catalog] }) };
|
|
293
|
+
} });
|
|
294
|
+
const handlers = createVoyagesRpcHandlers(s.ctx, { cloudClient: cloud, baseUrl: cloud.baseUrl, profileRoot: s.profileRoot });
|
|
295
|
+
await handlers.snapshot({}, call);
|
|
296
|
+
assert.deepEqual(wire.map(r => r.url), ['https://api.example.test/v1/voyages/bootstrap', 'https://api.example.test/v1/voyages/shop']);
|
|
297
|
+
for (const r of wire) assert.deepEqual(r.headers, { accept: 'application/json', authorization: 'Bearer secret-fixture' });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('shop reason permits empty text and null but all three flags remain strict booleans', async () => {
|
|
301
|
+
for (const reason of ['', null]) {
|
|
302
|
+
const s = setup(), original = s.cloud.request;
|
|
303
|
+
s.cloud.request = async (path, input) => path.endsWith('/shop') ? { items: [{ ...catalog, reason }] } : original(path, input);
|
|
304
|
+
assert.equal((await s.handlers.snapshot({}, call)).items[0].reason, reason);
|
|
305
|
+
}
|
|
306
|
+
for (const field of ['available', 'eligible', 'requiresPurchase']) {
|
|
307
|
+
const s = setup(), original = s.cloud.request;
|
|
308
|
+
s.cloud.request = async (path, input) => path.endsWith('/shop') ? { items: [{ ...catalog, [field]: 1 }] } : original(path, input);
|
|
309
|
+
await rejected(s.handlers.snapshot({}, call), 'invalid_response');
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('profile journal is fresh across workspace contexts, private, bounded and profile isolated', async () => {
|
|
314
|
+
const s = setup(), first = createVoyagesRpcHandlers(s.ctx, { profileRoot: s.profileRoot, cloudClient: s.cloud, now: () => NOW });
|
|
315
|
+
await getQuote(first);
|
|
316
|
+
s.cloud.request = async () => { throw new Error('unknown'); };
|
|
317
|
+
await rejected(first.purchase({ quoteId: 'quote-1' }, call), 'request_failed', true);
|
|
318
|
+
const directory = join(s.profileRoot, 'state', 'carthagent', 'voyages');
|
|
319
|
+
const [name] = (await readdir(directory)).filter(n => n.endsWith('.json'));
|
|
320
|
+
const file = join(directory, name), saved = JSON.parse(await readFile(file, 'utf8'));
|
|
321
|
+
// Windows uses inherited ACLs; stat mode bits cannot verify owner-only access there.
|
|
322
|
+
if (process.platform !== 'win32') {
|
|
323
|
+
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
|
324
|
+
assert.equal((await stat(directory)).mode & 0o777, 0o700);
|
|
325
|
+
}
|
|
326
|
+
assert.equal(JSON.stringify(saved).includes('secret-fixture'), false);
|
|
327
|
+
const otherWorkspace = { ...s.ctx, storage: { get() { throw new Error('location storage must not be used'); } } };
|
|
328
|
+
let pair;
|
|
329
|
+
const cloud = { request: async (_path, input) => { pair = input.body; return receipt; } };
|
|
330
|
+
const second = createVoyagesRpcHandlers(otherWorkspace, { profileRoot: s.profileRoot, cloudClient: cloud, now: () => NOW });
|
|
331
|
+
assert.deepEqual(await second.retryPurchase({ quoteId: 'quote-1' }, call), receipt);
|
|
332
|
+
assert.deepEqual(pair, { quoteId: saved.pending.quoteId, key: saved.pending.key });
|
|
333
|
+
await rejected(first.retryPurchase({ quoteId: 'quote-1' }, call), 'no_pending_purchase');
|
|
334
|
+
const isolatedRoot = await mkdtemp(join(testRoot, 'isolated-'));
|
|
335
|
+
await rejected(createVoyagesRpcHandlers(otherWorkspace, { profileRoot: isolatedRoot, cloudClient: cloud }).retryPurchase({ quoteId: 'quote-1' }, call), 'no_pending_purchase');
|
|
336
|
+
await writeFile(file, 'x'.repeat(65537));
|
|
337
|
+
await rejected(second.retryPurchase({ quoteId: 'quote-1' }, call), 'invalid_response');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('separate native processes serialize profile purchases and restart replays exact pair', async () => {
|
|
341
|
+
const profileRoot = await mkdtemp(join(testRoot, 'processes-'));
|
|
342
|
+
const moduleUrl = fileURLToPath(new URL('../plugins/carthagent/src/server/voyages-rpc.ts', import.meta.url));
|
|
343
|
+
const source = `
|
|
344
|
+
import { createRequire } from 'node:module';
|
|
345
|
+
import { appendFile } from 'node:fs/promises';
|
|
346
|
+
const { createJiti } = createRequire(import.meta.url)('jiti');
|
|
347
|
+
const { createVoyagesRpcHandlers } = await createJiti(import.meta.url).import(${JSON.stringify(moduleUrl)});
|
|
348
|
+
const ctx = { integration: { connection: {
|
|
349
|
+
active: async () => ({ id: 'shared-credential', type: 'credential', method: 'oauth' }),
|
|
350
|
+
resolve: async () => ({ type: 'oauth', methodID: 'carthagent-device', access: 'not-persisted' }),
|
|
351
|
+
} } };
|
|
352
|
+
const cloud = { request: async (path, input) => {
|
|
353
|
+
if (path.endsWith('/quotes')) return { quote: ${JSON.stringify(authoritativeQuote)} };
|
|
354
|
+
await appendFile(${JSON.stringify(join(profileRoot, 'sent.jsonl'))}, JSON.stringify(input.body) + '\\n');
|
|
355
|
+
if (process.argv[1] === 'crash') process.exit(17);
|
|
356
|
+
if (process.argv[1] === 'retry') return ${JSON.stringify(receipt)};
|
|
357
|
+
await new Promise(r => setTimeout(r, 150));
|
|
358
|
+
throw new Error('unknown');
|
|
359
|
+
} };
|
|
360
|
+
const handlers = createVoyagesRpcHandlers(ctx, { profileRoot: ${JSON.stringify(profileRoot)}, cloudClient: cloud, now: () => ${NOW} });
|
|
361
|
+
const call = { signal: new AbortController().signal, error(type, message, data) { throw Object.assign(new Error(message), { data }); } };
|
|
362
|
+
try {
|
|
363
|
+
const mode = process.argv[1];
|
|
364
|
+
const result = mode === 'quote' ? await handlers.quote({ slot: 'hat', item: 'captain' }, call)
|
|
365
|
+
: mode === 'retry' ? await handlers.retryPurchase({ quoteId: 'quote-1' }, call)
|
|
366
|
+
: await handlers.purchase({ quoteId: 'quote-1' }, call);
|
|
367
|
+
console.log(JSON.stringify(result));
|
|
368
|
+
} catch (e) { console.log(JSON.stringify(e.data)); }
|
|
369
|
+
`;
|
|
370
|
+
function child(mode) {
|
|
371
|
+
return new Promise((resolve, reject) => {
|
|
372
|
+
const p = spawn(process.execPath, ['--input-type=module', '-e', source, mode], { cwd: fileURLToPath(new URL('..', import.meta.url)), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
373
|
+
let out = '', err = '';
|
|
374
|
+
p.stdout.on('data', chunk => { out += chunk; }); p.stderr.on('data', chunk => { err += chunk; });
|
|
375
|
+
p.on('error', reject); p.on('close', code => {
|
|
376
|
+
if (code !== 0) return reject(new Error(`child_exit_${code}: ${err}`));
|
|
377
|
+
try { resolve(JSON.parse(out.trim())); } catch (error) { reject(error); }
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
await child('quote');
|
|
382
|
+
const results = await Promise.all([child('purchase'), child('purchase')]);
|
|
383
|
+
assert.deepEqual(results.map(r => r.code).sort(), ['purchase_pending', 'request_failed']);
|
|
384
|
+
const sent = () => readFile(join(profileRoot, 'sent.jsonl'), 'utf8').then(text => text.trim().split('\n').map(JSON.parse));
|
|
385
|
+
assert.equal((await sent()).length, 1);
|
|
386
|
+
assert.deepEqual(await child('retry'), receipt);
|
|
387
|
+
const pairs = await sent();
|
|
388
|
+
assert.equal(pairs.length, 2); assert.deepEqual(pairs[0], pairs[1]);
|
|
389
|
+
// Simulate a native service dying after the durable pair and request send.
|
|
390
|
+
await child('quote');
|
|
391
|
+
await assert.rejects(child('crash'), /child_exit_17/);
|
|
392
|
+
assert.deepEqual(await child('retry'), receipt);
|
|
393
|
+
const afterCrash = await sent();
|
|
394
|
+
assert.equal(afterCrash.length, 4); assert.deepEqual(afterCrash[2], afterCrash[3]);
|
|
395
|
+
});
|