dsh-agent-team-ui-testing 0.1.1 → 0.1.3

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -36
  3. package/annotation-capture.js +111 -0
  4. package/browser-safety.js +45 -1
  5. package/client.js +3721 -132
  6. package/context-menu.js +134 -0
  7. package/core/clock.js +88 -0
  8. package/core/config-store.js +566 -0
  9. package/core/config.js +892 -0
  10. package/core/contracts.js +240 -0
  11. package/core/errors.js +156 -0
  12. package/core/evidence.js +141 -0
  13. package/core/geometry.js +217 -0
  14. package/core/identity.js +180 -0
  15. package/core/index.js +24 -0
  16. package/core/p0.js +1101 -0
  17. package/core/readiness.js +175 -0
  18. package/core/registry.js +239 -0
  19. package/core/tab-access.js +1350 -0
  20. package/docs/adapters.md +160 -0
  21. package/docs/architecture.md +44 -9
  22. package/docs/capability-tiers.md +20 -4
  23. package/docs/capture-p0.md +39 -2
  24. package/docs/diagnose-reason-codes.md +157 -0
  25. package/docs/element-picker.md +24 -6
  26. package/docs/getting-started.md +47 -1
  27. package/docs/images/tutorial-01-overview.png +0 -0
  28. package/docs/images/tutorial-02-history-dropdown.png +0 -0
  29. package/docs/images/tutorial-03-pick-element.png +0 -0
  30. package/docs/images/tutorial-04-annotate.png +0 -0
  31. package/docs/index.md +11 -7
  32. package/docs/multi-tab.md +55 -20
  33. package/docs/network-p0.md +2 -2
  34. package/docs/panel-context-menu.md +41 -0
  35. package/docs/t2-structured-gui.md +2 -2
  36. package/docs/t3-vision.md +6 -1
  37. package/docs/t4-dynamic-behavior.md +1 -0
  38. package/docs/tools.md +65 -7
  39. package/docs/tutorial.md +125 -0
  40. package/docs/verification.md +26 -2
  41. package/docs/viewport-control.md +21 -8
  42. package/index.js +3263 -251
  43. package/integration/cross-environment.js +1535 -0
  44. package/integration/diagnose.js +945 -0
  45. package/integration/tab-principal.js +493 -0
  46. package/locale/en.json +1 -1
  47. package/locale/zh.json +1 -1
  48. package/network-p0.js +24 -7
  49. package/package.json +4 -3
  50. package/select-control.js +298 -0
  51. package/skills/ui-testing-common/SKILL.md +54 -0
  52. package/skills/ui-testing-fake/SKILL.md +43 -0
  53. package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
  54. package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
  55. package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
  56. package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
  57. package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
  58. package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
  59. package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
  60. package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
  61. package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
  62. package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
  63. package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
  64. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  65. package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
  66. package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
  67. package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
  68. package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
  69. package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
  70. package/targets/fake/adapter.js +1163 -0
  71. package/targets/fake/fixtures.js +177 -0
  72. package/targets/web/adapter.js +1231 -0
  73. package/targets/web/provider.js +415 -0
  74. package/skills/ui-testing/references/scenarios/annotations.md +0 -87
  75. /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
  76. /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
  77. /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
  78. /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
  79. /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
  80. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
  81. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
  82. /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
  83. /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
  84. /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
  85. /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
  86. /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
  87. /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
  88. /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Public contract constants for the cross-environment foundation (v1).
3
+ *
4
+ * This module is pure data: no I/O, no clocks, no browser/platform imports. Every constant is
5
+ * frozen so adapters and the host share one authoritative vocabulary instead of re-declaring
6
+ * string literals. See docs/internal/cross-environment-foundation-m0.md §2-§5.
7
+ */
8
+
9
+ /** Contract version shared by all three adapter classes. A mismatch is ADAPTER_UNSUPPORTED. */
10
+ export const CONTRACT_VERSION = 1;
11
+
12
+ /** The three adapter classes; each has a different call direction (M0 §2). */
13
+ export const ADAPTER_CLASSES = Object.freeze({
14
+ TARGET: 'target',
15
+ CAPABILITY: 'capability',
16
+ P0: 'p0',
17
+ });
18
+
19
+ /** Engineering support: does an implementation exist for this environment/capability? */
20
+ export const SUPPORT = Object.freeze({
21
+ SUPPORTED: 'supported',
22
+ UNSUPPORTED: 'unsupported',
23
+ PLANNED: 'planned',
24
+ });
25
+
26
+ /** Live availability: can the operation run right now? Never conflate with support. */
27
+ export const AVAILABILITY = Object.freeze({
28
+ AVAILABLE: 'available',
29
+ UNAVAILABLE: 'unavailable',
30
+ NOT_PROBED: 'not-probed',
31
+ BUSY: 'busy',
32
+ });
33
+
34
+ /** Per-axis readiness statuses. Axes are reported independently, never as one boolean. */
35
+ export const READINESS_STATUS = Object.freeze({
36
+ OK: 'ok',
37
+ DEGRADED: 'degraded',
38
+ FAILED: 'failed',
39
+ NOT_PROBED: 'not-probed',
40
+ UNSUPPORTED: 'unsupported',
41
+ DISABLED: 'disabled',
42
+ BOUND: 'bound',
43
+ UNBOUND: 'unbound',
44
+ STALE: 'stale',
45
+ });
46
+
47
+ /** The six readiness axes, in report order (M0 §5.2). */
48
+ export const READINESS_AXES = Object.freeze([
49
+ 'support',
50
+ 'enabled',
51
+ 'dependency',
52
+ 'connection',
53
+ 'target',
54
+ 'capability',
55
+ ]);
56
+
57
+ /**
58
+ * Web runtime providers and their engineering support — the **single truth** for "can this browser
59
+ * actually execute?".
60
+ *
61
+ * - `supported`: a driver exists (chrome).
62
+ * - `pending`: the user may express intent (`providers.<id>.enabled=true`) but no driver exists yet,
63
+ * so it is *not* an implemented provider: registration does not count it, readiness reports
64
+ * `not-probed / provider-driver-pending`, execution refuses with ADAPTER_UNSUPPORTED + `driver-pending`.
65
+ * - `unsupported`: no driver and none planned in this milestone.
66
+ *
67
+ * Flipping `edge` to `supported` is the one and only switch point (slice ③, once the vendor launcher,
68
+ * candidate paths and product-identity check exist). Never add a second "is edge usable?" judgement.
69
+ */
70
+ export const WEB_PROVIDER_SUPPORT = Object.freeze({
71
+ chrome: 'supported',
72
+ // Slice ③: the Edge vendor launcher exists, so `edge` is implemented. **This is the one switch
73
+ // point** — driver, readiness, registration and execution all derive from this table.
74
+ edge: 'supported',
75
+ firefox: 'unsupported',
76
+ });
77
+
78
+ /**
79
+ * Machine-readable provider `reason` values. One concept, one spelling: driver refusal, readiness
80
+ * and tool errors must all use these constants (a `reason` is a detail, never a public error code).
81
+ */
82
+ export const WEB_PROVIDER_REASONS = Object.freeze({
83
+ /** A provider whose driver is not wired yet (firefox today). */
84
+ DRIVER_PENDING: 'driver-pending',
85
+ /** No such provider id at all. */
86
+ UNKNOWN_PROVIDER: 'unknown-provider',
87
+ /** The user (or workspace) switch is off. */
88
+ PROVIDER_DISABLED: 'provider-disabled',
89
+ /** The whole target environment is off. */
90
+ ENVIRONMENT_DISABLED: 'environment-disabled',
91
+ /** Provider has no driver and none is planned. */
92
+ PROVIDER_UNSUPPORTED: 'provider-unsupported',
93
+ /** This provider's executable is missing on this machine. */
94
+ EXECUTABLE_MISSING: 'executable-missing',
95
+ /** An explicit override exists but is not usable (never a reason to run another vendor). */
96
+ OVERRIDE_INVALID: 'override-invalid',
97
+ /** The launched product gave no identity evidence and the provider needs it (non-chrome). */
98
+ IDENTITY_UNVERIFIED: 'identity-unverified',
99
+ /** The launched product identified as a different vendor. */
100
+ IDENTITY_MISMATCH: 'identity-mismatch',
101
+ });
102
+
103
+ /**
104
+ * Product-identity rule per vendor, used after the transport is up ("CDP connected" is not proof).
105
+ *
106
+ * `requires`: substrings the `Browser.getVersion.product` string must contain.
107
+ * `forbids`: substrings that must not appear (chrome must not be an Edge endpoint).
108
+ * `failClosedOnMissing`: no evidence is a rejection (multi-vendor providers) or tolerated
109
+ * (chrome, whose already-accepted startup path must not gain a new failure mode).
110
+ * A new vendor is one line here — never another if/else in the driver.
111
+ */
112
+ export const WEB_PROVIDER_VENDOR = Object.freeze({
113
+ chrome: Object.freeze({ requires: Object.freeze(['Chrome/']), forbids: Object.freeze(['Edg/']), failClosedOnMissing: false }),
114
+ edge: Object.freeze({ requires: Object.freeze(['Edg/']), forbids: Object.freeze([]), failClosedOnMissing: true }),
115
+ });
116
+
117
+ /**
118
+ * Profile directory suffix per provider: Chrome and Edge must never share a headed profile.
119
+ */
120
+ export const WEB_PROVIDER_PROFILE_SUFFIX = Object.freeze({ chrome: 'chrome', edge: 'edge', firefox: 'firefox' });
121
+
122
+ /** Known environments. Only web/fake have implementations in this milestone. */
123
+ export const ENVIRONMENTS = Object.freeze({
124
+ WEB: 'web',
125
+ FAKE: 'fake',
126
+ LINUX: 'linux-desktop',
127
+ ANDROID: 'android',
128
+ MINIPROGRAM: 'mini-program',
129
+ });
130
+
131
+ /**
132
+ * Canonical capability ids per environment. This is the adapter catalog: it is intentionally
133
+ * independent from `enabledEnvironments`, which only decides whether an environment may be used.
134
+ */
135
+ export const ENVIRONMENT_CAPABILITIES = Object.freeze({
136
+ web: Object.freeze([
137
+ 'web.t1',
138
+ 'web.t2',
139
+ 'web.t3',
140
+ 'web.t4',
141
+ 'web.network',
142
+ 'web.tabs',
143
+ // P0-D annotations (docs/viewport-control.md): the Agent annotation write surface.
144
+ 'web.p0.annotations',
145
+ 'web.p0.capture',
146
+ 'web.p0.network',
147
+ 'web.p0.display',
148
+ 'web.p0.canvas',
149
+ 'web.p0.targets',
150
+ ]),
151
+ fake: Object.freeze([
152
+ 'fake.t1',
153
+ 'fake.t2',
154
+ 'fake.t3',
155
+ 'fake.network',
156
+ 'fake.p0.capture',
157
+ 'fake.p0.network',
158
+ 'fake.p0.display',
159
+ 'fake.p0.canvas',
160
+ 'fake.p0.targets',
161
+ ]),
162
+ });
163
+
164
+ /** Named capability ids used by tools/readiness. `p0.network` is part of the adapter catalog. */
165
+ export const CAPABILITY_IDS = Object.freeze({
166
+ WEB: Object.freeze({
167
+ T1: 'web.t1',
168
+ T2: 'web.t2',
169
+ T3: 'web.t3',
170
+ T4: 'web.t4',
171
+ NETWORK: 'web.network',
172
+ TABS: 'web.tabs',
173
+ ANNOTATIONS: 'web.p0.annotations',
174
+ CAPTURE: 'web.p0.capture',
175
+ DISPLAY: 'web.p0.display',
176
+ CANVAS: 'web.p0.canvas',
177
+ TARGETS: 'web.p0.targets',
178
+ }),
179
+ FAKE: Object.freeze({
180
+ T1: 'fake.t1',
181
+ T2: 'fake.t2',
182
+ T3: 'fake.t3',
183
+ NETWORK: 'fake.network',
184
+ CAPTURE: 'fake.p0.capture',
185
+ DISPLAY: 'fake.p0.display',
186
+ CANVAS: 'fake.p0.canvas',
187
+ TARGETS: 'fake.p0.targets',
188
+ }),
189
+ });
190
+
191
+ /** Stable machine error codes. Unknown codes normalize to INTERNAL in core/errors.js. */
192
+ export const ERROR_CODES = Object.freeze({
193
+ ADAPTER_NOT_FOUND: 'ADAPTER_NOT_FOUND',
194
+ ADAPTER_UNSUPPORTED: 'ADAPTER_UNSUPPORTED',
195
+ ADAPTER_DISABLED: 'ADAPTER_DISABLED',
196
+ CONFIG_CONFLICT: 'CONFIG_CONFLICT',
197
+ CONFIG_INVALID: 'CONFIG_INVALID',
198
+ CONFIG_STALE: 'CONFIG_STALE',
199
+ TARGET_NOT_BOUND: 'TARGET_NOT_BOUND',
200
+ TARGET_GONE: 'TARGET_GONE',
201
+ GENERATION_MISMATCH: 'GENERATION_MISMATCH',
202
+ CAPABILITY_UNSUPPORTED: 'CAPABILITY_UNSUPPORTED',
203
+ CAPABILITY_UNAVAILABLE: 'CAPABILITY_UNAVAILABLE',
204
+ DEPENDENCY_MISSING: 'DEPENDENCY_MISSING',
205
+ CONNECTION_UNTRUSTED: 'CONNECTION_UNTRUSTED',
206
+ PROBE_TIMEOUT: 'PROBE_TIMEOUT',
207
+ PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE',
208
+ EVIDENCE_INCOMPLETE: 'EVIDENCE_INCOMPLETE',
209
+ FORBIDDEN: 'FORBIDDEN',
210
+ // Team/page access model (M1, ruling §7.3). Registered here as well as in ERROR_TAXONOMY so
211
+ // the constant table and the classification table cannot drift apart: a code that exists in one
212
+ // and not the other is exactly how a permission refusal ends up reported as INTERNAL.
213
+ TAB_FORBIDDEN: 'TAB_FORBIDDEN',
214
+ TAB_NOT_FOUND: 'TAB_NOT_FOUND',
215
+ TAB_LIMIT: 'TAB_LIMIT',
216
+ TAB_CONTROL_CHANGED: 'TAB_CONTROL_CHANGED',
217
+ TAB_OWNERSHIP_STALE: 'TAB_OWNERSHIP_STALE',
218
+ TAB_IDENTITY_UNAVAILABLE: 'TAB_IDENTITY_UNAVAILABLE',
219
+ TAB_OBSERVATION_STALE: 'TAB_OBSERVATION_STALE',
220
+ OBSERVATION_UNAVAILABLE: 'OBSERVATION_UNAVAILABLE',
221
+ RUNTIME_LIMIT: 'RUNTIME_LIMIT',
222
+ INVALID_ARGUMENT: 'INVALID_ARGUMENT',
223
+ OUTPUT_WRITE_FAILED: 'OUTPUT_WRITE_FAILED',
224
+ INTERNAL: 'INTERNAL',
225
+ });
226
+
227
+ /** All known capability ids as a lookup set (web + fake adapter catalogs). */
228
+ const CAPABILITY_ID_SET = new Set(
229
+ Object.values(ENVIRONMENT_CAPABILITIES).flatMap((ids) => ids),
230
+ );
231
+
232
+ /**
233
+ * True when `id` is a capability id declared by a shipped adapter catalog.
234
+ *
235
+ * Deliberately membership-based: an arbitrary dotted string is not a capability. New
236
+ * environments must add their catalog above before their ids become valid.
237
+ */
238
+ export function isCapabilityId(id) {
239
+ return typeof id === 'string' && CAPABILITY_ID_SET.has(id);
240
+ }
package/core/errors.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Normalized error type for the cross-environment foundation.
3
+ *
4
+ * Adapters may throw anything; callers branch on `code`/`category`/`retryable` instead of message
5
+ * text. Unknown codes degrade to INTERNAL without losing the original message or details.
6
+ */
7
+ import { ERROR_CODES } from './contracts.js';
8
+
9
+ /** Error categories shared by every adapter class. */
10
+ export const ERROR_CATEGORIES = Object.freeze({
11
+ INPUT: 'input',
12
+ STATE: 'state',
13
+ CAPABILITY: 'capability',
14
+ ENVIRONMENT: 'environment',
15
+ CONFIG: 'config',
16
+ TARGET: 'target',
17
+ EVIDENCE: 'evidence',
18
+ // `authorization` is deliberately its own category (ruling §7.3): "you may not do this" is a
19
+ // policy answer, not a broken capability and not a bug. Folding it into `capability` would make
20
+ // a permission refusal indistinguishable from "this browser cannot do that at all".
21
+ AUTHORIZATION: 'authorization',
22
+ // `resource` is a declared budget (4/8 pages, 32 metadata entries), which is a capacity answer
23
+ // rather than a target answer, so it never collapses into `target`.
24
+ RESOURCE: 'resource',
25
+ INTERNAL: 'internal',
26
+ });
27
+
28
+ /**
29
+ * code -> [category, retryable]. Retryable means "the same call may succeed later" (busy,
30
+ * timeout, gone target), not "the input was wrong".
31
+ */
32
+ export const ERROR_TAXONOMY = Object.freeze({
33
+ ADAPTER_NOT_FOUND: [ERROR_CATEGORIES.ENVIRONMENT, false],
34
+ ADAPTER_UNSUPPORTED: [ERROR_CATEGORIES.CAPABILITY, false],
35
+ ADAPTER_DISABLED: [ERROR_CATEGORIES.CONFIG, false],
36
+ CONFIG_CONFLICT: [ERROR_CATEGORIES.CONFIG, true],
37
+ CONFIG_INVALID: [ERROR_CATEGORIES.CONFIG, false],
38
+ CONFIG_STALE: [ERROR_CATEGORIES.CONFIG, true],
39
+ TARGET_NOT_BOUND: [ERROR_CATEGORIES.TARGET, false],
40
+ TARGET_GONE: [ERROR_CATEGORIES.TARGET, true],
41
+ GENERATION_MISMATCH: [ERROR_CATEGORIES.STATE, false],
42
+ CAPABILITY_UNSUPPORTED: [ERROR_CATEGORIES.CAPABILITY, false],
43
+ CAPABILITY_UNAVAILABLE: [ERROR_CATEGORIES.ENVIRONMENT, true],
44
+ DEPENDENCY_MISSING: [ERROR_CATEGORIES.ENVIRONMENT, false],
45
+ CONNECTION_UNTRUSTED: [ERROR_CATEGORIES.ENVIRONMENT, true],
46
+ PROBE_TIMEOUT: [ERROR_CATEGORIES.ENVIRONMENT, true],
47
+ PROVIDER_UNAVAILABLE: [ERROR_CATEGORIES.ENVIRONMENT, true],
48
+ EVIDENCE_INCOMPLETE: [ERROR_CATEGORIES.EVIDENCE, false],
49
+ FORBIDDEN: [ERROR_CATEGORIES.CAPABILITY, false],
50
+ // Team/page access model (M1, ruling §7.3). These are *classified* answers, so they must be
51
+ // registered here: `AdapterError` normalizes an unknown code to INTERNAL, which would turn a
52
+ // permission or staleness refusal into "the plugin is broken" for every caller that branches on
53
+ // `category`. The three legacy tab codes were only known to browser-safety.js and degraded to
54
+ // INTERNAL on this side; registering them here is the missing half of the same table.
55
+ TAB_FORBIDDEN: [ERROR_CATEGORIES.AUTHORIZATION, false],
56
+ TAB_NOT_FOUND: [ERROR_CATEGORIES.TARGET, false],
57
+ TAB_LIMIT: [ERROR_CATEGORIES.RESOURCE, false],
58
+ TAB_CONTROL_CHANGED: [ERROR_CATEGORIES.STATE, false],
59
+ TAB_OWNERSHIP_STALE: [ERROR_CATEGORIES.STATE, false],
60
+ TAB_IDENTITY_UNAVAILABLE: [ERROR_CATEGORIES.AUTHORIZATION, false],
61
+ TAB_OBSERVATION_STALE: [ERROR_CATEGORIES.STATE, true],
62
+ OBSERVATION_UNAVAILABLE: [ERROR_CATEGORIES.CAPABILITY, false],
63
+ RUNTIME_LIMIT: [ERROR_CATEGORIES.RESOURCE, true],
64
+ INVALID_ARGUMENT: [ERROR_CATEGORIES.INPUT, false],
65
+ OUTPUT_WRITE_FAILED: [ERROR_CATEGORIES.INTERNAL, true],
66
+ INTERNAL: [ERROR_CATEGORIES.INTERNAL, false],
67
+ });
68
+
69
+ const OPTION_KEYS = new Set(['category', 'retryable', 'details', 'cause']);
70
+
71
+ function isPlainObject(value) {
72
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ function normalizeCode(code) {
76
+ return typeof code === 'string' && Object.hasOwn(ERROR_TAXONOMY, code)
77
+ ? code
78
+ : ERROR_CODES.INTERNAL;
79
+ }
80
+
81
+ /**
82
+ * Error thrown by core helpers and adapters.
83
+ *
84
+ * @param {string} code Stable machine code; unknown codes become INTERNAL.
85
+ * @param {string} [message] Human-readable message. When omitted, a code-derived message is used.
86
+ * @param {object} [detailsOrOptions] Either a plain details object, or
87
+ * `{category, retryable, details, cause}`. A bare object containing any of those keys is
88
+ * treated as options; anything else is treated as `details`.
89
+ */
90
+ export class AdapterError extends Error {
91
+ constructor(code = ERROR_CODES.INTERNAL, message, detailsOrOptions) {
92
+ const known = typeof code === 'string' && Object.hasOwn(ERROR_TAXONOMY, code);
93
+ const normalized = normalizeCode(code);
94
+ const [defaultCategory, defaultRetryable] = ERROR_TAXONOMY[normalized];
95
+
96
+ const looksLikeOptions = isPlainObject(detailsOrOptions)
97
+ && Object.keys(detailsOrOptions).some((key) => OPTION_KEYS.has(key));
98
+ const options = looksLikeOptions ? detailsOrOptions : {};
99
+ const bareDetails = isPlainObject(detailsOrOptions) && !looksLikeOptions
100
+ ? detailsOrOptions
101
+ : undefined;
102
+
103
+ const details = options.details !== undefined ? options.details : bareDetails;
104
+ const resolvedMessage = typeof message === 'string' && message.length > 0
105
+ ? message
106
+ : `${normalized}${details === undefined ? '' : ': operation failed'}`;
107
+
108
+ super(resolvedMessage);
109
+ this.name = 'AdapterError';
110
+ this.code = normalized;
111
+ this.category = options.category ?? defaultCategory;
112
+ this.retryable = options.retryable ?? defaultRetryable;
113
+ this.details = details === undefined ? {} : details;
114
+ if (!known && typeof code === 'string' && code.length > 0) {
115
+ // Preserve what the caller asked for without pretending it is a supported code.
116
+ this.details = { ...this.details, requestedCode: code };
117
+ }
118
+ if (options.cause !== undefined) this.cause = options.cause;
119
+ }
120
+
121
+ /** JSON-safe representation; `cause` is summarized rather than serialized. */
122
+ payload() {
123
+ const payload = {
124
+ name: this.name,
125
+ code: this.code,
126
+ category: this.category,
127
+ retryable: this.retryable,
128
+ message: this.message,
129
+ details: this.details ?? {},
130
+ };
131
+ if (this.cause !== undefined) {
132
+ payload.cause = this.cause instanceof Error
133
+ ? { name: this.cause.name, message: this.cause.message }
134
+ : String(this.cause);
135
+ }
136
+ return payload;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Coerce any thrown value into an AdapterError.
142
+ *
143
+ * - AdapterError passes through unchanged (the original code is preserved).
144
+ * - Error keeps its message and gains `details.causeName`.
145
+ * - Any other value is stringified, never silently dropped.
146
+ */
147
+ export function asAdapterError(error, fallbackCode = ERROR_CODES.INTERNAL) {
148
+ if (error instanceof AdapterError) return error;
149
+ if (error instanceof Error) {
150
+ return new AdapterError(fallbackCode, error.message, {
151
+ details: { causeName: error.name },
152
+ cause: error,
153
+ });
154
+ }
155
+ return new AdapterError(fallbackCode, String(error));
156
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Evidence envelopes and a bounded in-memory store.
3
+ *
4
+ * Every observation must be traceable to scope/session/target/generation/configRevision/
5
+ * operation/source/capturedAt. The store is intentionally memory-only: writing evidence files is
6
+ * the host's job, not a pure core concern.
7
+ */
8
+ import { CONTRACT_VERSION, ERROR_CODES } from './contracts.js';
9
+ import { AdapterError } from './errors.js';
10
+
11
+ /** Required envelope keys (values may be null where the concept genuinely does not exist). */
12
+ export const EVIDENCE_REQUIRED_FIELDS = Object.freeze([
13
+ 'version',
14
+ 'scope',
15
+ 'session',
16
+ 'target',
17
+ 'generation',
18
+ 'configRevision',
19
+ 'operation',
20
+ 'source',
21
+ 'capturedAt',
22
+ ]);
23
+
24
+ function isPlainObject(value) {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
26
+ }
27
+
28
+ /**
29
+ * @param {{scope?:object, session?:object, target?:object|null, generation?:number|null,
30
+ * configRevision?:number|null, operation?:object, source?:object, capturedAt?:number|null,
31
+ * extra?:object}} [input]
32
+ */
33
+ export function createEvidenceEnvelope({
34
+ scope = null,
35
+ session = null,
36
+ target = null,
37
+ generation = null,
38
+ configRevision = null,
39
+ operation = null,
40
+ source = null,
41
+ capturedAt = null,
42
+ extra,
43
+ } = {}) {
44
+ const extras = isPlainObject(extra) ? extra : {};
45
+ return {
46
+ ...extras,
47
+ version: CONTRACT_VERSION,
48
+ scope,
49
+ session,
50
+ target,
51
+ generation,
52
+ configRevision,
53
+ operation,
54
+ source,
55
+ capturedAt,
56
+ };
57
+ }
58
+
59
+ /** Shallow-merge a patch into an envelope; the version stays fixed at CONTRACT_VERSION. */
60
+ export function mergeEvidence(base, patch) {
61
+ const left = isPlainObject(base) ? base : {};
62
+ const right = isPlainObject(patch) ? patch : {};
63
+ const merged = { ...left };
64
+ for (const [key, value] of Object.entries(right)) {
65
+ if (value !== undefined) merged[key] = value;
66
+ }
67
+ merged.version = CONTRACT_VERSION;
68
+ return merged;
69
+ }
70
+
71
+ /**
72
+ * @returns {{ok:boolean, missing:string[]}} `missing` lists required keys that are absent or
73
+ * explicitly undefined; explicit `null` is accepted (e.g. an unbound target).
74
+ */
75
+ export function validateEvidence(envelope) {
76
+ if (!isPlainObject(envelope)) {
77
+ return { ok: false, missing: [...EVIDENCE_REQUIRED_FIELDS] };
78
+ }
79
+ const missing = EVIDENCE_REQUIRED_FIELDS.filter(
80
+ (field) => !Object.hasOwn(envelope, field) || envelope[field] === undefined,
81
+ );
82
+ return { ok: missing.length === 0, missing };
83
+ }
84
+
85
+ let storeCounter = 0;
86
+
87
+ /**
88
+ * Bounded FIFO evidence store.
89
+ *
90
+ * `add` assigns an `evidenceId` when absent; `get` looks it up. No file I/O and no implicit
91
+ * timestamps.
92
+ */
93
+ export class EvidenceStore {
94
+ constructor({ limit = 256 } = {}) {
95
+ if (!Number.isInteger(limit) || limit <= 0) {
96
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'EvidenceStore limit must be a positive integer', {
97
+ details: { field: 'limit', value: Number.isFinite(limit) ? limit : null },
98
+ });
99
+ }
100
+ this.limit = limit;
101
+ this._entries = [];
102
+ this._byId = new Map();
103
+ }
104
+
105
+ /** Store an envelope; oldest entries are evicted once the limit is exceeded. */
106
+ add(envelope) {
107
+ if (!isPlainObject(envelope)) {
108
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'evidence envelope must be an object', {
109
+ details: { field: 'envelope' },
110
+ });
111
+ }
112
+ const evidenceId = typeof envelope.evidenceId === 'string' && envelope.evidenceId.length > 0
113
+ ? envelope.evidenceId
114
+ : `ev-${++storeCounter}`;
115
+ const entry = { ...envelope, evidenceId };
116
+ this._entries.push(entry);
117
+ this._byId.set(evidenceId, entry);
118
+ while (this._entries.length > this.limit) {
119
+ const evicted = this._entries.shift();
120
+ this._byId.delete(evicted.evidenceId);
121
+ }
122
+ return entry;
123
+ }
124
+
125
+ get(evidenceId) {
126
+ return this._byId.get(evidenceId);
127
+ }
128
+
129
+ list() {
130
+ return [...this._entries];
131
+ }
132
+
133
+ clear() {
134
+ this._entries = [];
135
+ this._byId.clear();
136
+ }
137
+
138
+ get size() {
139
+ return this._entries.length;
140
+ }
141
+ }