carthagent 1.0.7 → 1.1.0-beta.2
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/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/cloud-onboarding-acceptance.md +65 -0
- 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/launch-readiness-live-results.md +65 -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 +175 -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-beta.1.md +60 -0
- package/docs/release-notes-beta.2.md +60 -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/cloud/client.mjs +13 -7
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1188 -0
- package/lib/opencode/cli.mjs +253 -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 +22 -5
- 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/providers/names.mjs +1 -1
- package/lib/tui/framing.mjs +1 -80
- package/lib/tui/models.mjs +1 -1
- 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 +243 -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 +65 -36
- 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 +71 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +506 -0
- package/plugins/carthagent/src/ui/client.ts +127 -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 +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -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 +64 -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/cloud-client.test.mjs +2 -2
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/experiential-extension.test.mjs +2 -2
- package/tests/experiential.test.mjs +1 -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 +55 -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 +525 -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 +33 -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-guidance.test.mjs +16 -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 +83 -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 +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -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 +248 -20
- package/tests/opencode-runtime.test.mjs +597 -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 +231 -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/tui.test.mjs +2 -2
- 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 +45 -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 +882 -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 +91 -65
- 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,778 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createDailyQuestsState, dailyErrorCode, dailySubmissionLabel } from '../plugins/carthagent/src/ui/daily-quests-state.mjs';
|
|
4
|
+
import { dailyRequest } from '../plugins/carthagent/src/daily-evidence.mjs';
|
|
5
|
+
import { dailyCancellationNotice, handleDailyQuestsEvent } from '../plugins/carthagent/src/ui/daily-quests-events.mjs';
|
|
6
|
+
|
|
7
|
+
const ACCOUNT = 'a'.repeat(64);
|
|
8
|
+
const OTHER = 'b'.repeat(64);
|
|
9
|
+
const QUEST = '11111111-1111-4111-8111-111111111111';
|
|
10
|
+
const SUB = '22222222-2222-4222-8222-222222222222';
|
|
11
|
+
const NEXT = '33333333-3333-4333-8333-333333333333';
|
|
12
|
+
const clone = value => JSON.parse(JSON.stringify(value));
|
|
13
|
+
const evidence = () => ({ projectId: QUEST, outcomeId: SUB, runId: NEXT, revision: 'a'.repeat(40), goal: ' Verify a selected change ', change: 'A bounded change', checks: [{ name: 'unit', before: 'failed', after: 'passed', revision: 'a'.repeat(40), result: '1 passing check' }], findings: [] });
|
|
14
|
+
const quest = (id = QUEST) => ({ id, status: 'open', definition: { title: 'Selected quest' } });
|
|
15
|
+
const submission = (status = 'queued', decision = null) => ({ id: SUB, questId: QUEST, status, decision, rewards: false, assessmentBasis: 'tui_submitted', nextAttemptAt: null, errorCode: null });
|
|
16
|
+
const record = () => ({ id: SUB, questId: QUEST, createdAt: '2026-01-01', request: dailyRequest(SUB, evidence()), submission: null });
|
|
17
|
+
const snapshot = () => ({ accountKey: ACCOUNT, slate: { day: '2026-01-01', items: [quest()] }, records: [] });
|
|
18
|
+
const deferred = () => { let resolve; let reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
|
|
19
|
+
const rpcError = (code = 'network_error') => ({ type: 'dailyQuestError', message: 'secret credential and raw payload', data: { code, uncertain: true, status: 0 } });
|
|
20
|
+
|
|
21
|
+
function fixture(overrides = {}, options = {}) {
|
|
22
|
+
const reads = [];
|
|
23
|
+
const writes = [];
|
|
24
|
+
const history = [];
|
|
25
|
+
let remote = snapshot();
|
|
26
|
+
let latest;
|
|
27
|
+
const api = {
|
|
28
|
+
snapshot: async signal => { reads.push(['snapshot', signal]); return clone(remote); },
|
|
29
|
+
detail: async ({ id }, signal) => { reads.push(['detail', id, signal]); return { ...quest(id), latestSubmission: clone(remote.records.find(entry => entry.questId === id)?.submission ?? null) }; },
|
|
30
|
+
submit: async (input, signal) => {
|
|
31
|
+
writes.push(['submit', clone(input), signal]);
|
|
32
|
+
remote.records.push({ id: input.id, questId: input.questId, request: { id: input.id, consent: input.consent, evidence: clone(input.evidence) }, submission: submission() });
|
|
33
|
+
return { submission: submission() };
|
|
34
|
+
},
|
|
35
|
+
retry: async (input, signal) => { writes.push(['retry', clone(input), signal]); return { submission: submission() }; },
|
|
36
|
+
poll: async (input, signal) => { reads.push(['poll', input.id, signal]); return { submission: submission() }; },
|
|
37
|
+
forgetPayload: async (input, signal) => { writes.push(['forget', clone(input), signal]); remote.records.find(entry => entry.id === input.id).request = null; return { ok: true }; },
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
const controller = createDailyQuestsState({ api, changed: next => { latest = next; history.push(next); }, ...options });
|
|
41
|
+
return { controller, api, reads, writes, history, view: () => latest, remote: () => remote, setRemote: next => { remote = next; } };
|
|
42
|
+
}
|
|
43
|
+
async function compose(f) {
|
|
44
|
+
await f.controller.refresh();
|
|
45
|
+
await f.controller.select(QUEST);
|
|
46
|
+
await f.controller.compose(async () => ({ id: SUB, evidence: evidence(), provenance: 'Selected local result; review before sharing.' }));
|
|
47
|
+
}
|
|
48
|
+
async function preview(f) { await compose(f); f.controller.preview(); }
|
|
49
|
+
|
|
50
|
+
// Model the pinned SelectRenderable's numeric-index retention/clamping. The
|
|
51
|
+
// production helper must call its real setSelectedIndex before options shrink.
|
|
52
|
+
function eventPage(f, { selectedIndex = 4, interrupted = true } = {}) {
|
|
53
|
+
let options = ['refresh', 'signin', 'back', 'quest', 'submit'];
|
|
54
|
+
let notice = '';
|
|
55
|
+
let closed = 0;
|
|
56
|
+
const order = [];
|
|
57
|
+
const collapse = () => { options = options.slice(0, 3); selectedIndex = Math.min(selectedIndex, options.length - 1); };
|
|
58
|
+
const actions = {
|
|
59
|
+
isDestroyed: false,
|
|
60
|
+
setSelectedIndex(index) {
|
|
61
|
+
order.push('reset');
|
|
62
|
+
assert.ok(f.view().snapshot, 'reset happens before protected state is cleared');
|
|
63
|
+
if (index >= 0 && index < options.length) selectedIndex = index;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const handle = (event, changedLocation = false) => handleDailyQuestsEvent({
|
|
67
|
+
controller: {
|
|
68
|
+
invalidate: () => { order.push('invalidate'); f.controller.invalidate(); collapse(); },
|
|
69
|
+
refresh: () => { order.push('refresh'); return f.controller.refresh(); },
|
|
70
|
+
},
|
|
71
|
+
event, location: { directory: '/selected', workspaceID: 'w1' }, changedLocation, interrupted, actions,
|
|
72
|
+
clearReplay: () => { order.push('clearReplay'); collapse(); },
|
|
73
|
+
closeDialog: () => { order.push('closeDialog'); closed++; },
|
|
74
|
+
notice: text => { notice = text; },
|
|
75
|
+
});
|
|
76
|
+
return { handle, actions, order, notice: () => notice, closed: () => closed, selected: () => options[selectedIndex] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const event of [
|
|
80
|
+
{ type: 'credential.switched', data: { integrationID: 'experiential-labs' } },
|
|
81
|
+
{ type: 'credential.updated' }, { type: 'provider.updated' },
|
|
82
|
+
{ type: 'integration.updated' }, { type: 'server.connected' }, { type: 'server.disconnected' },
|
|
83
|
+
{ type: 'session.updated', changedLocation: true },
|
|
84
|
+
{ type: 'server.disconnected', changedLocation: true },
|
|
85
|
+
]) {
|
|
86
|
+
test(`daily ${event.type}${event.changedLocation ? ' with changed location' : ''} cancels confirmation and resets late Enter to Refresh`, async () => {
|
|
87
|
+
const f = fixture();
|
|
88
|
+
await preview(f);
|
|
89
|
+
const confirmation = deferred(), next = deferred();
|
|
90
|
+
let live;
|
|
91
|
+
const submitting = f.controller.submit((_shown, isLive) => { live = isLive; return confirmation.promise; });
|
|
92
|
+
assert.equal(f.view().busy, true);
|
|
93
|
+
const page = eventPage(f);
|
|
94
|
+
f.api.snapshot = () => next.promise;
|
|
95
|
+
const refreshing = page.handle(event, event.changedLocation);
|
|
96
|
+
assert.equal(live(), false);
|
|
97
|
+
assert.equal(f.view().snapshot, undefined);
|
|
98
|
+
assert.equal(f.view().draft, undefined);
|
|
99
|
+
assert.equal(f.view().preview, undefined);
|
|
100
|
+
assert.equal(f.view().detail, undefined);
|
|
101
|
+
assert.equal(page.closed(), 1);
|
|
102
|
+
assert.deepEqual(page.order.slice(0, 4), ['reset', 'clearReplay', 'invalidate', 'closeDialog']);
|
|
103
|
+
assert.equal(page.selected(), 'refresh', 'delayed Enter cannot activate Back');
|
|
104
|
+
assert.equal(page.notice(), dailyCancellationNotice);
|
|
105
|
+
const clearing = event.type === 'server.disconnected';
|
|
106
|
+
assert.equal(f.view().busy, !clearing);
|
|
107
|
+
confirmation.resolve(true);
|
|
108
|
+
await submitting;
|
|
109
|
+
assert.deepEqual(f.writes, [], 'a late true consent must never call api.submit');
|
|
110
|
+
assert.equal(f.view().busy, !clearing, 'stale callback cannot unlock the new refresh');
|
|
111
|
+
next.resolve({ ...snapshot(), accountKey: OTHER });
|
|
112
|
+
await refreshing;
|
|
113
|
+
assert.equal(f.view().busy, false);
|
|
114
|
+
assert.equal(f.view().snapshot?.accountKey, clearing ? undefined : OTHER);
|
|
115
|
+
assert.equal(page.notice(), dailyCancellationNotice, 'notice survives background refresh');
|
|
116
|
+
f.controller.dispose();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test('unrelated or differently scoped events leave confirmation, selection and notice untouched', async () => {
|
|
121
|
+
const f = fixture();
|
|
122
|
+
await preview(f);
|
|
123
|
+
const confirmation = deferred();
|
|
124
|
+
const pending = f.controller.submit(() => confirmation.promise);
|
|
125
|
+
const page = eventPage(f);
|
|
126
|
+
for (const event of [
|
|
127
|
+
{ type: 'session.updated' },
|
|
128
|
+
{ type: 'credential.switched', data: { integrationID: 'anthropic' } },
|
|
129
|
+
{ type: 'provider.updated', location: { directory: '/other', workspaceID: 'w1' } },
|
|
130
|
+
{ type: 'credential.updated', location: { directory: '/selected', workspaceID: 'w2' } },
|
|
131
|
+
]) await page.handle(event);
|
|
132
|
+
assert.equal(f.view().busy, true);
|
|
133
|
+
assert.equal(f.view().phase, 'preview');
|
|
134
|
+
assert.equal(page.selected(), 'submit');
|
|
135
|
+
assert.deepEqual(page.order, []);
|
|
136
|
+
assert.equal(page.notice(), '');
|
|
137
|
+
assert.equal(page.closed(), 0);
|
|
138
|
+
confirmation.resolve(false);
|
|
139
|
+
await pending;
|
|
140
|
+
assert.deepEqual(f.writes, []);
|
|
141
|
+
f.controller.dispose();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('event during dispatched submit aborts UI lifetime without claiming it withdrew evidence or replaying', async () => {
|
|
145
|
+
const f = fixture();
|
|
146
|
+
await preview(f);
|
|
147
|
+
const sent = deferred(), result = deferred();
|
|
148
|
+
let signal;
|
|
149
|
+
f.api.submit = async (input, abortSignal) => {
|
|
150
|
+
signal = abortSignal;
|
|
151
|
+
f.writes.push(['submit', clone(input)]);
|
|
152
|
+
// Model the durable journal written before the request was dispatched.
|
|
153
|
+
f.remote().records.push(record());
|
|
154
|
+
sent.resolve();
|
|
155
|
+
return result.promise;
|
|
156
|
+
};
|
|
157
|
+
const pending = f.controller.submit(async () => true);
|
|
158
|
+
await sent.promise;
|
|
159
|
+
const page = eventPage(f);
|
|
160
|
+
await page.handle({ type: 'credential.updated' });
|
|
161
|
+
assert.equal(signal.aborted, true);
|
|
162
|
+
assert.equal(page.selected(), 'refresh');
|
|
163
|
+
assert.match(page.notice(), /Nothing was resent automatically/);
|
|
164
|
+
assert.match(page.notice(), /check the recovery journal for any request already started/);
|
|
165
|
+
assert.doesNotMatch(page.notice(), /nothing (?:was )?(?:sent|uploaded)|no (?:upload|request was sent)/i);
|
|
166
|
+
assert.equal(f.view().draft, undefined);
|
|
167
|
+
assert.equal(f.view().preview, undefined);
|
|
168
|
+
assert.equal(f.view().snapshot.records[0].id, SUB);
|
|
169
|
+
assert.equal(f.view().snapshot.records[0].submission, null);
|
|
170
|
+
const reads = f.reads.length;
|
|
171
|
+
result.resolve({ submission: submission('graded', 'assessed_complete') });
|
|
172
|
+
await pending;
|
|
173
|
+
assert.equal(f.reads.length, reads, 'no stale reconciliation after the late result');
|
|
174
|
+
assert.equal(f.writes.length, 1, 'never resubmit an already-started request');
|
|
175
|
+
assert.equal(f.view().snapshot.records[0].submission, null, 'no success assumed');
|
|
176
|
+
assert.equal(f.view().busy, false);
|
|
177
|
+
f.controller.dispose();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('compose and recovery dialogs are canceled even without an evidence draft', async () => {
|
|
181
|
+
for (const action of ['compose', 'retry', 'forget']) {
|
|
182
|
+
const f = fixture();
|
|
183
|
+
f.remote().records.push(record());
|
|
184
|
+
await f.controller.refresh();
|
|
185
|
+
await f.controller.select(QUEST);
|
|
186
|
+
const waiting = deferred();
|
|
187
|
+
const callback = () => waiting.promise;
|
|
188
|
+
const pending = action === 'compose' ? f.controller.compose(callback) : f.controller[action](SUB, callback);
|
|
189
|
+
assert.equal(f.view().draft, undefined);
|
|
190
|
+
const page = eventPage(f);
|
|
191
|
+
await page.handle({ type: 'credential.updated' });
|
|
192
|
+
waiting.resolve(action === 'compose' ? { id: SUB, evidence: evidence() } : true);
|
|
193
|
+
await pending;
|
|
194
|
+
assert.equal(page.closed(), 1);
|
|
195
|
+
assert.equal(page.notice(), dailyCancellationNotice);
|
|
196
|
+
assert.equal(page.selected(), 'refresh');
|
|
197
|
+
assert.deepEqual(f.writes, []);
|
|
198
|
+
assert.equal(f.view().draft, undefined);
|
|
199
|
+
f.controller.dispose();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
for (const phase of ['edit', 'preview', 'replay']) {
|
|
204
|
+
test(`background invalidation visibly cancels ${phase} even without an active dialog`, async () => {
|
|
205
|
+
const f = fixture();
|
|
206
|
+
if (phase === 'replay') { f.remote().records.push(record()); await f.controller.refresh(); }
|
|
207
|
+
else if (phase === 'edit') await compose(f);
|
|
208
|
+
else await preview(f);
|
|
209
|
+
assert.equal(f.view().busy, false);
|
|
210
|
+
const page = eventPage(f);
|
|
211
|
+
await page.handle({ type: 'server.disconnected' });
|
|
212
|
+
assert.equal(page.notice(), dailyCancellationNotice);
|
|
213
|
+
assert.equal(page.selected(), 'refresh');
|
|
214
|
+
assert.equal(f.view().snapshot, undefined);
|
|
215
|
+
assert.equal(f.view().draft, undefined);
|
|
216
|
+
assert.equal(f.view().preview, undefined);
|
|
217
|
+
assert.deepEqual(f.writes, []);
|
|
218
|
+
f.controller.dispose();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test('ordinary background refresh needs no cancellation notice', async () => {
|
|
223
|
+
const f = fixture();
|
|
224
|
+
await f.controller.refresh();
|
|
225
|
+
const page = eventPage(f, { interrupted: false });
|
|
226
|
+
await page.handle({ type: 'credential.updated' });
|
|
227
|
+
assert.equal(page.notice(), '');
|
|
228
|
+
assert.equal(page.selected(), 'refresh');
|
|
229
|
+
assert.ok(f.view().snapshot);
|
|
230
|
+
f.controller.dispose();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('destroyed actions are never touched during background refresh', async () => {
|
|
234
|
+
const f = fixture();
|
|
235
|
+
await f.controller.refresh();
|
|
236
|
+
const page = eventPage(f, { interrupted: false });
|
|
237
|
+
page.actions.isDestroyed = true;
|
|
238
|
+
await page.handle({ type: 'credential.updated' });
|
|
239
|
+
assert.equal(page.notice(), '');
|
|
240
|
+
assert.equal(page.order.includes('reset'), false);
|
|
241
|
+
assert.ok(f.view().snapshot);
|
|
242
|
+
f.controller.dispose();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('selecting accepted detail immediately erases matching in-memory recovery evidence', async () => {
|
|
246
|
+
const accepted = submission();
|
|
247
|
+
const f = fixture({ detail: async () => ({ ...quest(), latestSubmission: accepted }) });
|
|
248
|
+
f.remote().records.push({ ...record(), submission: null });
|
|
249
|
+
await f.controller.refresh();
|
|
250
|
+
assert.ok(f.view().snapshot.records[0].request);
|
|
251
|
+
await f.controller.select(QUEST);
|
|
252
|
+
assert.equal(f.view().detail.latestSubmission.status, 'queued');
|
|
253
|
+
assert.equal(f.view().snapshot.records[0].request, null);
|
|
254
|
+
assert.deepEqual(f.view().snapshot.records[0].submission, accepted);
|
|
255
|
+
let confirmed = false;
|
|
256
|
+
await f.controller.retry(SUB, async () => { confirmed = true; return true; });
|
|
257
|
+
assert.equal(confirmed, false);
|
|
258
|
+
assert.deepEqual(f.writes, []);
|
|
259
|
+
f.controller.dispose();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('compose, preview and refresh only read; explicit cancellation performs zero writes', async () => {
|
|
263
|
+
const f = fixture();
|
|
264
|
+
await f.controller.refresh();
|
|
265
|
+
await f.controller.select(QUEST);
|
|
266
|
+
let args;
|
|
267
|
+
await f.controller.compose(async (...values) => { args = values; return undefined; });
|
|
268
|
+
assert.equal(args[0].id, QUEST);
|
|
269
|
+
assert.equal(args[1](), true);
|
|
270
|
+
assert.ok(args[2] instanceof AbortSignal);
|
|
271
|
+
assert.equal(f.view().draft, undefined);
|
|
272
|
+
await preview(f);
|
|
273
|
+
assert.equal(f.view().phase, 'preview');
|
|
274
|
+
await f.controller.submit(async () => false);
|
|
275
|
+
f.controller.cancelDraft();
|
|
276
|
+
f.remote().records.push(record());
|
|
277
|
+
await f.controller.refresh();
|
|
278
|
+
await f.controller.retry(SUB, async () => false);
|
|
279
|
+
await f.controller.forget(SUB, async (_record, live) => {
|
|
280
|
+
assert.equal(live(), true);
|
|
281
|
+
return false;
|
|
282
|
+
});
|
|
283
|
+
assert.deepEqual(f.writes, []);
|
|
284
|
+
assert.equal(f.reads.some(entry => entry[0] === 'poll'), false);
|
|
285
|
+
assert.equal(f.view().phase, 'overview');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('draft validation is local, bounded and correctable; edit/back discard consent', async () => {
|
|
289
|
+
const f = fixture();
|
|
290
|
+
await compose(f);
|
|
291
|
+
const before = f.reads.length;
|
|
292
|
+
for (const text of ['{bad secret', JSON.stringify({ ...evidence(), change: 'x'.repeat(3073) }), JSON.stringify({ ...evidence(), unknown: true }), JSON.stringify({ ...evidence(), checks: Array(13).fill(evidence().checks[0]) })]) {
|
|
293
|
+
f.controller.edit(text);
|
|
294
|
+
f.controller.preview();
|
|
295
|
+
assert.equal(f.view().phase, 'edit');
|
|
296
|
+
assert.equal(f.view().preview, undefined);
|
|
297
|
+
assert.equal(f.view().draft.text, text);
|
|
298
|
+
assert.match(f.view().error, /^invalid_daily_evidence\./);
|
|
299
|
+
assert.doesNotMatch(f.view().error, /secret/);
|
|
300
|
+
await f.controller.submit(async () => { assert.fail('invalid preview must not confirm'); });
|
|
301
|
+
}
|
|
302
|
+
f.controller.edit(JSON.stringify(evidence()));
|
|
303
|
+
f.controller.preview();
|
|
304
|
+
assert.equal(f.view().draft.text, JSON.stringify(dailyRequest(SUB, evidence()).evidence, null, 2));
|
|
305
|
+
assert.equal(f.view().draft.provenance, 'Selected local result; review before sharing.');
|
|
306
|
+
assert.equal(f.view().preview.consent, true);
|
|
307
|
+
f.controller.edit(f.view().draft.text);
|
|
308
|
+
assert.equal(f.view().preview, undefined);
|
|
309
|
+
f.controller.preview();
|
|
310
|
+
f.controller.backToEdit();
|
|
311
|
+
assert.equal(f.view().preview, undefined);
|
|
312
|
+
assert.equal(f.view().phase, 'edit');
|
|
313
|
+
assert.equal(f.reads.length, before);
|
|
314
|
+
assert.deepEqual(f.writes, []);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('emitted snapshots, drafts and previews are detached from authoritative controller data', async () => {
|
|
318
|
+
const f = fixture();
|
|
319
|
+
await preview(f);
|
|
320
|
+
const expected = dailyRequest(SUB, evidence());
|
|
321
|
+
f.view().snapshot.accountKey = OTHER;
|
|
322
|
+
f.view().snapshot.slate.items.length = 0;
|
|
323
|
+
f.view().detail.id = NEXT;
|
|
324
|
+
f.view().draft.id = NEXT;
|
|
325
|
+
f.view().preview.evidence.change = 'Injected mutable output';
|
|
326
|
+
await f.controller.submit(async shown => {
|
|
327
|
+
assert.deepEqual(shown.request, expected);
|
|
328
|
+
assert.equal(shown.quest.id, QUEST);
|
|
329
|
+
assert.ok(Object.isFrozen(shown.request.evidence));
|
|
330
|
+
try { shown.request.evidence.change = 'Changed in confirmation'; } catch {}
|
|
331
|
+
shown.request = dailyRequest(NEXT, { ...evidence(), change: 'Replacement' });
|
|
332
|
+
return true;
|
|
333
|
+
});
|
|
334
|
+
assert.deepEqual(f.writes[0][1], { accountKey: ACCOUNT, questId: QUEST, ...expected });
|
|
335
|
+
assert.equal(f.writes.length, 1);
|
|
336
|
+
assert.equal(f.view().draft, undefined);
|
|
337
|
+
assert.equal(f.view().preview, undefined);
|
|
338
|
+
assert.equal(f.view().detail.latestSubmission.status, 'queued');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('confirmation requires literal true; compose requires selected open authoritative detail', async () => {
|
|
342
|
+
const f = fixture();
|
|
343
|
+
await f.controller.compose(async () => { assert.fail('missing detail'); });
|
|
344
|
+
await f.controller.refresh();
|
|
345
|
+
await f.controller.select(NEXT);
|
|
346
|
+
assert.equal(f.reads.filter(entry => entry[0] === 'detail').length, 0);
|
|
347
|
+
await f.controller.select(QUEST);
|
|
348
|
+
f.api.detail = async () => ({ ...quest(), status: 'assessed_complete', latestSubmission: null });
|
|
349
|
+
await f.controller.select(QUEST);
|
|
350
|
+
await f.controller.compose(async () => { assert.fail('closed quest'); });
|
|
351
|
+
f.api.detail = async () => ({ ...quest(), latestSubmission: null });
|
|
352
|
+
await preview(f);
|
|
353
|
+
for (const result of [undefined, 1, 'yes', {}, false]) await f.controller.submit(async () => result);
|
|
354
|
+
assert.deepEqual(f.writes, []);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('selecting another known quest clears draft and reads detail, never fabricates it', async () => {
|
|
358
|
+
const f = fixture();
|
|
359
|
+
f.remote().slate.items.push(quest(NEXT));
|
|
360
|
+
await preview(f);
|
|
361
|
+
await f.controller.select(NEXT);
|
|
362
|
+
assert.equal(f.view().detail.id, NEXT);
|
|
363
|
+
assert.equal(f.view().draft, undefined);
|
|
364
|
+
assert.equal(f.view().preview, undefined);
|
|
365
|
+
assert.equal(f.view().phase, 'overview');
|
|
366
|
+
assert.equal(f.writes.length, 0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('busy guard does not enqueue edits, refreshes, selections or writes', async () => {
|
|
370
|
+
const f = fixture();
|
|
371
|
+
await preview(f);
|
|
372
|
+
const waiting = deferred();
|
|
373
|
+
const text = f.view().draft.text;
|
|
374
|
+
const reads = f.reads.length;
|
|
375
|
+
const pending = f.controller.submit(() => waiting.promise);
|
|
376
|
+
assert.equal(f.view().busy, true);
|
|
377
|
+
f.controller.edit('changed');
|
|
378
|
+
f.controller.preview();
|
|
379
|
+
await f.controller.refresh();
|
|
380
|
+
await f.controller.select(QUEST);
|
|
381
|
+
await f.controller.compose(async () => { assert.fail('busy compose'); });
|
|
382
|
+
await f.controller.submit(async () => { assert.fail('busy submit'); });
|
|
383
|
+
await f.controller.retry(SUB, async () => { assert.fail('busy retry'); });
|
|
384
|
+
await f.controller.forget(SUB, async () => { assert.fail('busy forget'); });
|
|
385
|
+
await f.controller.poll(SUB);
|
|
386
|
+
assert.equal(f.view().draft.text, text);
|
|
387
|
+
assert.equal(f.reads.length, reads);
|
|
388
|
+
waiting.resolve(true);
|
|
389
|
+
await pending;
|
|
390
|
+
assert.equal(f.writes.length, 1);
|
|
391
|
+
assert.equal(f.view().busy, false);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
for (const action of ['compose', 'submit', 'retry', 'forget']) {
|
|
395
|
+
for (const cancellation of ['invalidate', 'dispose', 'cancelDraft']) {
|
|
396
|
+
test(`${cancellation} cancels awaited ${action} and prevents writes/stale state`, async () => {
|
|
397
|
+
const f = fixture();
|
|
398
|
+
f.remote().records.push(record());
|
|
399
|
+
await preview(f);
|
|
400
|
+
const waiting = deferred();
|
|
401
|
+
let live;
|
|
402
|
+
let signal;
|
|
403
|
+
const callback = (_shown, isLive, abortSignal) => { live = isLive; signal = abortSignal; return waiting.promise; };
|
|
404
|
+
const pending = action === 'compose' ? f.controller.compose(callback)
|
|
405
|
+
: action === 'submit' ? f.controller.submit(callback)
|
|
406
|
+
: f.controller[action](SUB, callback);
|
|
407
|
+
assert.equal(live(), true);
|
|
408
|
+
f.controller[cancellation]();
|
|
409
|
+
assert.equal(live(), false);
|
|
410
|
+
if (action === 'compose') assert.equal(signal.aborted, true);
|
|
411
|
+
waiting.resolve(action === 'compose' ? { id: SUB, evidence: evidence(), provenance: 'stale' } : true);
|
|
412
|
+
await pending;
|
|
413
|
+
assert.deepEqual(f.writes, []);
|
|
414
|
+
assert.equal(f.view().draft, undefined);
|
|
415
|
+
assert.equal(f.view().preview, undefined);
|
|
416
|
+
assert.equal(f.view().busy, false);
|
|
417
|
+
if (cancellation !== 'cancelDraft') assert.equal(f.view().snapshot, undefined);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
test('back-to-edit revokes an outstanding submit confirmation', async () => {
|
|
423
|
+
const f = fixture();
|
|
424
|
+
await preview(f);
|
|
425
|
+
const waiting = deferred();
|
|
426
|
+
const pending = f.controller.submit(() => waiting.promise);
|
|
427
|
+
f.controller.backToEdit();
|
|
428
|
+
assert.equal(f.view().phase, 'edit');
|
|
429
|
+
assert.equal(f.view().preview, undefined);
|
|
430
|
+
waiting.resolve(true);
|
|
431
|
+
await pending;
|
|
432
|
+
assert.deepEqual(f.writes, []);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('account switch during confirmation cannot send old evidence; stale GET is ignored', async () => {
|
|
436
|
+
const f = fixture();
|
|
437
|
+
await preview(f);
|
|
438
|
+
const waiting = deferred();
|
|
439
|
+
const pending = f.controller.submit(() => waiting.promise);
|
|
440
|
+
f.controller.invalidate();
|
|
441
|
+
f.setRemote({ ...snapshot(), accountKey: OTHER });
|
|
442
|
+
await f.controller.refresh();
|
|
443
|
+
waiting.resolve(true);
|
|
444
|
+
await pending;
|
|
445
|
+
assert.equal(f.view().snapshot.accountKey, OTHER);
|
|
446
|
+
assert.deepEqual(f.writes, []);
|
|
447
|
+
const oldRead = deferred();
|
|
448
|
+
f.api.snapshot = () => oldRead.promise;
|
|
449
|
+
const stale = f.controller.refresh();
|
|
450
|
+
f.controller.invalidate();
|
|
451
|
+
f.api.snapshot = async () => ({ ...snapshot(), accountKey: ACCOUNT });
|
|
452
|
+
await f.controller.refresh();
|
|
453
|
+
oldRead.resolve({ ...snapshot(), accountKey: OTHER });
|
|
454
|
+
await stale;
|
|
455
|
+
assert.equal(f.view().snapshot.accountKey, ACCOUNT);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('stale selected detail and stale failed requests cannot overwrite new identity', async () => {
|
|
459
|
+
for (const reject of [false, true]) {
|
|
460
|
+
const f = fixture();
|
|
461
|
+
await f.controller.refresh();
|
|
462
|
+
const old = deferred();
|
|
463
|
+
f.api.detail = () => old.promise;
|
|
464
|
+
const pending = f.controller.select(QUEST);
|
|
465
|
+
f.controller.invalidate();
|
|
466
|
+
f.setRemote({ ...snapshot(), accountKey: OTHER });
|
|
467
|
+
await f.controller.refresh();
|
|
468
|
+
if (reject) old.reject(rpcError()); else old.resolve({ ...quest(), latestSubmission: submission() });
|
|
469
|
+
await pending;
|
|
470
|
+
assert.equal(f.view().snapshot.accountKey, OTHER);
|
|
471
|
+
assert.equal(f.view().detail, undefined);
|
|
472
|
+
assert.equal(f.view().error, '');
|
|
473
|
+
assert.equal(f.reads.filter(entry => entry[0] === 'snapshot').length, 2);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('unknown submit clears raw UI then reads exact same-ID journal without replay or assumed success', async () => {
|
|
478
|
+
const f = fixture();
|
|
479
|
+
await preview(f);
|
|
480
|
+
f.api.submit = async (input, signal) => {
|
|
481
|
+
f.writes.push(['submit', clone(input), signal]);
|
|
482
|
+
f.remote().records.push({ ...record(), request: { id: input.id, consent: true, evidence: clone(input.evidence) }, submission: null });
|
|
483
|
+
throw rpcError();
|
|
484
|
+
};
|
|
485
|
+
const start = f.history.length;
|
|
486
|
+
await f.controller.submit(async () => true);
|
|
487
|
+
assert.equal(f.writes.length, 1);
|
|
488
|
+
assert.ok(f.history.slice(start).some(state => state.error && !state.snapshot && !state.detail && !state.draft && !state.preview));
|
|
489
|
+
assert.equal(f.view().draft, undefined);
|
|
490
|
+
assert.equal(f.view().preview, undefined);
|
|
491
|
+
assert.equal(f.view().detail, undefined);
|
|
492
|
+
assert.equal(f.view().snapshot.records[0].id, SUB);
|
|
493
|
+
assert.deepEqual(f.view().snapshot.records[0].request, dailyRequest(SUB, evidence()));
|
|
494
|
+
assert.equal(f.view().snapshot.records[0].submission, null);
|
|
495
|
+
assert.match(f.view().error, /^network_error\..*no success assumed/);
|
|
496
|
+
await f.controller.refresh();
|
|
497
|
+
assert.equal(f.writes.length, 1);
|
|
498
|
+
await f.controller.retry(SUB, async shown => {
|
|
499
|
+
assert.deepEqual(shown.request, dailyRequest(SUB, evidence()));
|
|
500
|
+
assert.ok(Object.isFrozen(shown.request.evidence));
|
|
501
|
+
return true;
|
|
502
|
+
});
|
|
503
|
+
assert.deepEqual(f.writes.map(entry => [entry[0], entry[1].id]), [['submit', SUB], ['retry', SUB]]);
|
|
504
|
+
assert.deepEqual(f.writes[1][1], { accountKey: ACCOUNT, id: SUB });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('unknown failure without a durable record never preserves raw evidence or offers retry', async () => {
|
|
508
|
+
const f = fixture({ submit: async () => { throw rpcError(); } });
|
|
509
|
+
await preview(f);
|
|
510
|
+
await f.controller.submit(async () => true);
|
|
511
|
+
await f.controller.retry(SUB, async () => { assert.fail('no durable payload'); });
|
|
512
|
+
assert.equal(f.view().draft, undefined);
|
|
513
|
+
assert.equal(f.view().preview, undefined);
|
|
514
|
+
assert.deepEqual(f.view().snapshot.records, []);
|
|
515
|
+
assert.deepEqual(f.writes, []);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('auth/conflict errors clear protected state, redact unsafe RPC content and never replay', async () => {
|
|
519
|
+
for (const method of ['submit', 'retry', 'forget']) {
|
|
520
|
+
for (const code of ['unauthorized', 'daily_conflict']) {
|
|
521
|
+
const f = fixture();
|
|
522
|
+
f.remote().records.push(record());
|
|
523
|
+
await preview(f);
|
|
524
|
+
const apiMethod = method === 'forget' ? 'forgetPayload' : method;
|
|
525
|
+
f.api[apiMethod] = async input => { f.writes.push([method, input]); throw rpcError(code); };
|
|
526
|
+
f.api.snapshot = async () => { throw new Error('another private secret'); };
|
|
527
|
+
if (method === 'submit') await f.controller.submit(async () => true);
|
|
528
|
+
else await f.controller[method](SUB, async () => true);
|
|
529
|
+
assert.equal(f.writes.length, 1);
|
|
530
|
+
assert.equal(f.view().snapshot, undefined);
|
|
531
|
+
assert.equal(f.view().detail, undefined);
|
|
532
|
+
assert.equal(f.view().draft, undefined);
|
|
533
|
+
assert.equal(f.view().preview, undefined);
|
|
534
|
+
assert.match(f.view().error, new RegExp(`^${code}\\.`));
|
|
535
|
+
assert.doesNotMatch(f.view().error, /secret|credential and raw/);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('a different account on successful or failed write reconciliation is not old-account success', async () => {
|
|
541
|
+
for (const fail of [false, true]) {
|
|
542
|
+
const f = fixture();
|
|
543
|
+
await preview(f);
|
|
544
|
+
f.api.submit = async input => {
|
|
545
|
+
f.writes.push(['submit', input]);
|
|
546
|
+
f.setRemote({ ...snapshot(), accountKey: OTHER, records: [record()] });
|
|
547
|
+
if (fail) throw rpcError();
|
|
548
|
+
return { submission: submission() };
|
|
549
|
+
};
|
|
550
|
+
await f.controller.submit(async () => true);
|
|
551
|
+
assert.equal(f.writes.length, 1);
|
|
552
|
+
assert.equal(f.view().snapshot, undefined);
|
|
553
|
+
assert.equal(f.view().detail, undefined);
|
|
554
|
+
assert.equal(f.view().draft, undefined);
|
|
555
|
+
assert.match(f.view().error, /^daily_account_changed\./);
|
|
556
|
+
await f.controller.refresh();
|
|
557
|
+
assert.equal(f.view().snapshot.accountKey, OTHER);
|
|
558
|
+
assert.equal(f.view().error, '');
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('refresh hides old protected state synchronously and explicit refresh may load a new account', async () => {
|
|
563
|
+
const f = fixture();
|
|
564
|
+
await preview(f);
|
|
565
|
+
const next = deferred();
|
|
566
|
+
f.api.snapshot = () => next.promise;
|
|
567
|
+
const pending = f.controller.refresh();
|
|
568
|
+
assert.equal(f.view().snapshot, undefined);
|
|
569
|
+
assert.equal(f.view().detail, undefined);
|
|
570
|
+
assert.equal(f.view().draft, undefined);
|
|
571
|
+
assert.equal(f.view().preview, undefined);
|
|
572
|
+
next.resolve({ ...snapshot(), accountKey: OTHER });
|
|
573
|
+
await pending;
|
|
574
|
+
assert.equal(f.view().snapshot.accountKey, OTHER);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('retry is ID-bound to the shown saved request, no edited payload or fresh UUID', async () => {
|
|
578
|
+
const f = fixture();
|
|
579
|
+
f.remote().records.push(record());
|
|
580
|
+
await f.controller.refresh();
|
|
581
|
+
await f.controller.retry(SUB, async shown => {
|
|
582
|
+
assert.equal(shown.id, SUB);
|
|
583
|
+
assert.deepEqual(shown.request, dailyRequest(SUB, evidence()));
|
|
584
|
+
f.remote().records.push({ ...record(), id: NEXT, request: dailyRequest(NEXT, evidence()) });
|
|
585
|
+
return true;
|
|
586
|
+
});
|
|
587
|
+
assert.deepEqual(f.writes.map(entry => [entry[0], entry[1]]), [['retry', { accountKey: ACCOUNT, id: SUB }]]);
|
|
588
|
+
assert.equal(f.view().draft, undefined);
|
|
589
|
+
assert.equal(f.view().preview, undefined);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('forget requires a retained request and preserves authoritative metadata and ID', async () => {
|
|
593
|
+
const f = fixture();
|
|
594
|
+
f.remote().records.push(record());
|
|
595
|
+
await f.controller.refresh();
|
|
596
|
+
await f.controller.forget(NEXT, async () => { assert.fail('unknown record'); });
|
|
597
|
+
for (const result of [undefined, 1, 'yes', {}, false]) await f.controller.forget(SUB, async () => result);
|
|
598
|
+
assert.deepEqual(f.writes, []);
|
|
599
|
+
assert.deepEqual(f.view().snapshot.records[0].request, dailyRequest(SUB, evidence()));
|
|
600
|
+
await f.controller.forget(SUB, async () => true);
|
|
601
|
+
assert.deepEqual(f.writes.map(entry => [entry[0], entry[1]]), [['forget', { accountKey: ACCOUNT, id: SUB }]]);
|
|
602
|
+
assert.equal(f.view().snapshot.records[0].id, SUB);
|
|
603
|
+
assert.equal(f.view().snapshot.records[0].request, null);
|
|
604
|
+
assert.equal(f.view().snapshot.records[0].submission, null);
|
|
605
|
+
await f.controller.forget(SUB, async () => { assert.fail('already forgotten'); });
|
|
606
|
+
await f.controller.retry(SUB, async () => { assert.fail('cannot replay forgotten evidence'); });
|
|
607
|
+
assert.equal(f.writes.length, 1);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('later-midnight saved record can select/poll original quest rather than current slate', async () => {
|
|
611
|
+
const f = fixture();
|
|
612
|
+
f.remote().slate = { day: '2026-01-02', items: [quest(NEXT)] };
|
|
613
|
+
f.remote().records.push(record());
|
|
614
|
+
await f.controller.refresh();
|
|
615
|
+
assert.equal(f.reads.some(entry => entry[0] === 'poll'), false);
|
|
616
|
+
await f.controller.select(QUEST);
|
|
617
|
+
assert.equal(f.view().detail.id, QUEST);
|
|
618
|
+
f.api.poll = async ({ id }) => {
|
|
619
|
+
f.reads.push(['poll', id]);
|
|
620
|
+
f.remote().records[0].submission = submission('graded', 'needs_evidence');
|
|
621
|
+
return { submission: clone(f.remote().records[0].submission) };
|
|
622
|
+
};
|
|
623
|
+
await f.controller.select(NEXT);
|
|
624
|
+
await f.controller.poll(SUB);
|
|
625
|
+
assert.equal(f.view().detail.id, QUEST);
|
|
626
|
+
assert.equal(f.view().detail.latestSubmission.decision, 'needs_evidence');
|
|
627
|
+
assert.equal(f.view().snapshot.records[0].submission.status, 'graded');
|
|
628
|
+
const reads = f.reads.length;
|
|
629
|
+
await f.controller.poll('unknown');
|
|
630
|
+
assert.equal(f.reads.length, reads);
|
|
631
|
+
assert.equal(f.writes.length, 0);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test('poll accepts detail.latestSubmission without a saved payload record', async () => {
|
|
635
|
+
const f = fixture({ detail: async ({ id }) => ({ ...quest(id), latestSubmission: submission('grading') }) });
|
|
636
|
+
await f.controller.refresh();
|
|
637
|
+
await f.controller.select(QUEST);
|
|
638
|
+
await f.controller.poll(SUB);
|
|
639
|
+
assert.equal(f.reads.filter(entry => entry[0] === 'poll').length, 1);
|
|
640
|
+
assert.equal(f.view().detail.id, QUEST);
|
|
641
|
+
assert.equal(f.view().detail.latestSubmission.status, 'grading');
|
|
642
|
+
assert.equal(f.writes.length, 0);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test('processing failure is not a negative coding assessment; graded does not mean verified complete', () => {
|
|
646
|
+
assert.equal(dailySubmissionLabel(submission()), 'Queued (not completed)');
|
|
647
|
+
assert.equal(dailySubmissionLabel(submission('grading')), 'Grading (not completed)');
|
|
648
|
+
assert.equal(dailySubmissionLabel(submission('failed', 'not_met')), 'Processing failed (not a coding assessment)');
|
|
649
|
+
assert.equal(dailySubmissionLabel(submission('graded', 'assessed_complete')), 'Classifier-assessed complete (not independently verified)');
|
|
650
|
+
assert.equal(dailySubmissionLabel(submission('graded', 'not_met')), 'Graded: not met');
|
|
651
|
+
assert.equal(dailySubmissionLabel(submission('graded', 'needs_evidence')), 'Graded: needs evidence');
|
|
652
|
+
assert.equal(dailySubmissionLabel(submission('graded')), 'Graded: assessment unavailable');
|
|
653
|
+
assert.equal(dailySubmissionLabel({ status: 'secret unknown status' }), 'Submission status unavailable');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('authoritative queued/grading/failed/graded detail is not synthesized from write response', async () => {
|
|
657
|
+
for (const [status, decision] of [['queued', null], ['grading', null], ['failed', null], ['graded', 'not_met'], ['graded', 'assessed_complete']]) {
|
|
658
|
+
const f = fixture();
|
|
659
|
+
await preview(f);
|
|
660
|
+
f.api.submit = async input => {
|
|
661
|
+
f.writes.push(['submit', input]);
|
|
662
|
+
f.remote().records.push({ ...record(), submission: submission(status, decision) });
|
|
663
|
+
return { submission: submission('graded', 'assessed_complete') };
|
|
664
|
+
};
|
|
665
|
+
await f.controller.submit(async () => true);
|
|
666
|
+
assert.equal(f.view().detail.latestSubmission.status, status);
|
|
667
|
+
assert.equal(f.view().detail.latestSubmission.decision, decision);
|
|
668
|
+
assert.equal(f.view().detail.status, 'open');
|
|
669
|
+
assert.equal(f.view().error, '');
|
|
670
|
+
assert.equal(f.view().busy, false);
|
|
671
|
+
assert.equal(f.writes.length, 1);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test('only constrained dailyQuestError data codes are renderable', () => {
|
|
676
|
+
assert.equal(dailyErrorCode(rpcError('daily_conflict')), 'daily_conflict');
|
|
677
|
+
for (const error of [new Error('secret'), { type: 'other', data: { code: 'daily_conflict' } }, { type: 'dailyQuestError', data: { code: 'secret text\n' } }, { type: 'dailyQuestError', data: { code: 'secret\n' } }, { type: 'dailyQuestError', data: { code: '' } }, { type: 'dailyQuestError', data: { code: 'x'.repeat(65) } }, { type: 'dailyQuestError', data: { code: '<token>' } }, { type: 'dailyQuestError', data: {} }]) {
|
|
678
|
+
assert.equal(dailyErrorCode(error), 'daily_request_failed');
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('human confirmation does not consume network timeout; every request starts a fresh timeout', async () => {
|
|
683
|
+
const timers = [];
|
|
684
|
+
const f = fixture({}, { timeoutSignal: () => { const timer = new AbortController(); timers.push(timer); return timer.signal; } });
|
|
685
|
+
await preview(f);
|
|
686
|
+
const previous = timers.length;
|
|
687
|
+
const waiting = deferred();
|
|
688
|
+
const pending = f.controller.submit(() => waiting.promise);
|
|
689
|
+
assert.equal(timers.length, previous);
|
|
690
|
+
timers.forEach(timer => timer.abort());
|
|
691
|
+
waiting.resolve(true);
|
|
692
|
+
await pending;
|
|
693
|
+
assert.equal(f.writes.length, 1);
|
|
694
|
+
assert.equal(f.writes[0][2].aborted, false);
|
|
695
|
+
assert.equal(timers.length, previous + 3);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('per-request timeout shows safe failure and read-only reconciliation', async () => {
|
|
699
|
+
const timers = [];
|
|
700
|
+
const started = deferred();
|
|
701
|
+
const f = fixture({}, { timeoutSignal: () => { const timer = new AbortController(); timers.push(timer); return timer.signal; } });
|
|
702
|
+
await preview(f);
|
|
703
|
+
f.api.submit = async (_input, signal) => {
|
|
704
|
+
started.resolve();
|
|
705
|
+
return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }));
|
|
706
|
+
};
|
|
707
|
+
const pending = f.controller.submit(async () => true);
|
|
708
|
+
await started.promise;
|
|
709
|
+
timers.at(-1).abort(new DOMException('secret unsafe timeout', 'TimeoutError'));
|
|
710
|
+
await pending;
|
|
711
|
+
assert.match(f.view().error, /^daily_request_failed\./);
|
|
712
|
+
assert.doesNotMatch(f.view().error, /secret/);
|
|
713
|
+
assert.equal(f.view().draft, undefined);
|
|
714
|
+
assert.equal(f.view().preview, undefined);
|
|
715
|
+
assert.equal(f.view().busy, false);
|
|
716
|
+
assert.equal(f.writes.length, 0);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test('cancellation of a dispatched write ignores its late result and never starts stale reconciliation', async () => {
|
|
720
|
+
for (const cancel of ['invalidate', 'dispose']) {
|
|
721
|
+
const f = fixture();
|
|
722
|
+
await preview(f);
|
|
723
|
+
const sent = deferred();
|
|
724
|
+
const result = deferred();
|
|
725
|
+
let signal;
|
|
726
|
+
f.api.submit = async (input, abortSignal) => {
|
|
727
|
+
f.writes.push(['submit', input]); signal = abortSignal; sent.resolve();
|
|
728
|
+
return result.promise;
|
|
729
|
+
};
|
|
730
|
+
const pending = f.controller.submit(async () => true);
|
|
731
|
+
await sent.promise;
|
|
732
|
+
const reads = f.reads.length;
|
|
733
|
+
f.controller[cancel]();
|
|
734
|
+
assert.equal(signal.aborted, true);
|
|
735
|
+
result.resolve({ submission: submission('graded', 'assessed_complete') });
|
|
736
|
+
await pending;
|
|
737
|
+
assert.equal(f.reads.length, reads);
|
|
738
|
+
assert.equal(f.writes.length, 1);
|
|
739
|
+
assert.equal(f.view().snapshot, undefined);
|
|
740
|
+
assert.equal(f.view().detail, undefined);
|
|
741
|
+
assert.equal(f.view().draft, undefined);
|
|
742
|
+
assert.equal(f.view().error, '');
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test('dispose aborts a pending snapshot and ignores a transport that resolves anyway', async () => {
|
|
747
|
+
const waiting = deferred();
|
|
748
|
+
let signal;
|
|
749
|
+
const f = fixture({ snapshot: abortSignal => { signal = abortSignal; return waiting.promise; } });
|
|
750
|
+
const pending = f.controller.refresh();
|
|
751
|
+
f.controller.dispose();
|
|
752
|
+
assert.equal(signal.aborted, true);
|
|
753
|
+
const events = f.history.length;
|
|
754
|
+
waiting.resolve(snapshot());
|
|
755
|
+
await pending;
|
|
756
|
+
await f.controller.refresh();
|
|
757
|
+
assert.equal(f.history.length, events);
|
|
758
|
+
assert.equal(f.view().snapshot, undefined);
|
|
759
|
+
assert.equal(f.view().busy, false);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('failed explicit retry reconciles same durable payload without a second retry', async () => {
|
|
763
|
+
const f = fixture();
|
|
764
|
+
f.remote().records.push({ ...record(), submission: null });
|
|
765
|
+
await f.controller.refresh();
|
|
766
|
+
f.api.retry = async input => { f.writes.push(['retry', input]); throw rpcError('key_conflict'); };
|
|
767
|
+
await f.controller.retry(SUB, async shown => {
|
|
768
|
+
assert.deepEqual(shown.request, dailyRequest(SUB, evidence()));
|
|
769
|
+
return true;
|
|
770
|
+
});
|
|
771
|
+
assert.deepEqual(f.writes, [['retry', { accountKey: ACCOUNT, id: SUB }]]);
|
|
772
|
+
assert.deepEqual(f.view().snapshot.records[0].request, dailyRequest(SUB, evidence()));
|
|
773
|
+
assert.match(f.view().error, /^key_conflict\./);
|
|
774
|
+
assert.equal(f.view().draft, undefined);
|
|
775
|
+
assert.equal(f.view().preview, undefined);
|
|
776
|
+
await f.controller.refresh();
|
|
777
|
+
assert.equal(f.writes.length, 1);
|
|
778
|
+
});
|