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
package/core/config.js ADDED
@@ -0,0 +1,892 @@
1
+ /**
2
+ * Configuration model and resolution chain (M0 §4).
3
+ *
4
+ * One authoritative chain: global -> workspace -> session, resolved into an immutable snapshot
5
+ * that every adapter consumes. Pure functions only; persistence lives in config-store.js.
6
+ */
7
+ import {
8
+ CONTRACT_VERSION,
9
+ ENVIRONMENTS,
10
+ ENVIRONMENT_CAPABILITIES,
11
+ ERROR_CODES,
12
+ WEB_PROVIDER_SUPPORT,
13
+ } from './contracts.js';
14
+ import { AdapterError } from './errors.js';
15
+ import { workspaceKeyOf } from './identity.js';
16
+
17
+ /** Persisted config schema version. */
18
+ export const CONFIG_VERSION = 1;
19
+
20
+ /**
21
+ * Web *runtime providers* (the browser that actually executes): one target environment (`web`),
22
+ * one provider per selection. This is a different axis from `environment` and from the P0
23
+ * `P0Provider` class — never conflate the names (see docs/internal/web-runtime-provider-contract.md §4.2).
24
+ */
25
+ export const WEB_PROVIDERS = Object.freeze({
26
+ CHROME: 'chrome',
27
+ EDGE: 'edge',
28
+ FIREFOX: 'firefox',
29
+ });
30
+
31
+ /** Known provider ids, in report order. Anything else is not a provider name. */
32
+ export const WEB_PROVIDER_IDS = Object.freeze([
33
+ WEB_PROVIDERS.CHROME,
34
+ WEB_PROVIDERS.EDGE,
35
+ WEB_PROVIDERS.FIREFOX,
36
+ ]);
37
+
38
+ /** Provider used when a legacy config has no `provider` field at all (config v1 compatibility). */
39
+ export const DEFAULT_WEB_PROVIDER = WEB_PROVIDERS.CHROME;
40
+
41
+ /** Engineering support for one provider id (`supported|pending|unsupported`, null when unknown). */
42
+ export function webProviderSupport(provider) {
43
+ return Object.hasOwn(WEB_PROVIDER_SUPPORT, provider) ? WEB_PROVIDER_SUPPORT[provider] : null;
44
+ }
45
+
46
+ /**
47
+ * Providers the execution layer can currently drive — **derived** from `WEB_PROVIDER_SUPPORT`, so the
48
+ * config layer, registration, readiness and execution can never disagree. `edge` is `pending` today:
49
+ * its switch is storable, but it does not count as implemented and never executes.
50
+ */
51
+ export const IMPLEMENTED_WEB_PROVIDERS = Object.freeze(
52
+ WEB_PROVIDER_IDS.filter((provider) => webProviderSupport(provider) === 'supported'),
53
+ );
54
+
55
+ /** True when a driver exists for this provider id. */
56
+ export function isImplementedWebProvider(provider) {
57
+ return webProviderSupport(provider) === 'supported';
58
+ }
59
+
60
+ /**
61
+ * Known environment metadata. `planned` environments are placeholders: they may be displayed but
62
+ * never enabled or selected in v1.
63
+ */
64
+ export const KNOWN_ENVIRONMENTS = Object.freeze({
65
+ [ENVIRONMENTS.WEB]: Object.freeze({
66
+ environment: ENVIRONMENTS.WEB,
67
+ status: 'supported',
68
+ testOnly: false,
69
+ planned: false,
70
+ }),
71
+ [ENVIRONMENTS.FAKE]: Object.freeze({
72
+ environment: ENVIRONMENTS.FAKE,
73
+ status: 'supported',
74
+ testOnly: true,
75
+ planned: false,
76
+ }),
77
+ [ENVIRONMENTS.LINUX]: Object.freeze({
78
+ environment: ENVIRONMENTS.LINUX,
79
+ status: 'planned',
80
+ testOnly: false,
81
+ planned: true,
82
+ }),
83
+ [ENVIRONMENTS.ANDROID]: Object.freeze({
84
+ environment: ENVIRONMENTS.ANDROID,
85
+ status: 'planned',
86
+ testOnly: false,
87
+ planned: true,
88
+ }),
89
+ [ENVIRONMENTS.MINIPROGRAM]: Object.freeze({
90
+ environment: ENVIRONMENTS.MINIPROGRAM,
91
+ status: 'planned',
92
+ testOnly: false,
93
+ planned: true,
94
+ }),
95
+ });
96
+
97
+ const PLANNED_ENVIRONMENTS = new Set(
98
+ Object.values(KNOWN_ENVIRONMENTS).filter((meta) => meta.planned).map((meta) => meta.environment),
99
+ );
100
+
101
+ /** 该目标环境是否仍是"规划中"(v1 没有适配器,可启用但不可执行)。 */
102
+ export function isPlannedEnvironment(environment) {
103
+ return PLANNED_ENVIRONMENTS.has(environment);
104
+ }
105
+
106
+ function deepFreeze(value) {
107
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
108
+ Object.freeze(value);
109
+ for (const child of Object.values(value)) deepFreeze(child);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ function clone(value) {
115
+ return structuredClone(value);
116
+ }
117
+
118
+ function isPlainObject(value) {
119
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
120
+ }
121
+
122
+ function invalid(message, details) {
123
+ return new AdapterError(ERROR_CODES.CONFIG_INVALID, message, { details });
124
+ }
125
+
126
+ function assertPlainObject(value, field) {
127
+ if (!isPlainObject(value)) throw invalid(`${field} must be an object`, { field });
128
+ return value;
129
+ }
130
+
131
+ function assertRevision(value, field = 'revision') {
132
+ if (!Number.isInteger(value) || value < 0) {
133
+ throw invalid(`${field} must be a non-negative integer`, { field, value: String(value) });
134
+ }
135
+ return value;
136
+ }
137
+
138
+ /**
139
+ * Per-provider switches for a fresh web environment entry: chrome on, the others off, and no
140
+ * second copy of the "which providers are enabled" list (it is always derived).
141
+ */
142
+ function defaultWebProviders() {
143
+ return {
144
+ [WEB_PROVIDERS.CHROME]: { enabled: true },
145
+ [WEB_PROVIDERS.EDGE]: { enabled: false },
146
+ [WEB_PROVIDERS.FIREFOX]: { enabled: false },
147
+ };
148
+ }
149
+
150
+ /** Default config: web is the only enabled environment, chrome the only enabled provider. */
151
+ export const DEFAULT_CONFIG = deepFreeze({
152
+ version: CONFIG_VERSION,
153
+ revision: 0,
154
+ global: {
155
+ environment: ENVIRONMENTS.WEB,
156
+ enabledEnvironments: [ENVIRONMENTS.WEB],
157
+ environments: {
158
+ [ENVIRONMENTS.WEB]: {
159
+ enabled: true,
160
+ targetId: null,
161
+ provider: DEFAULT_WEB_PROVIDER,
162
+ providers: {
163
+ [WEB_PROVIDERS.CHROME]: { enabled: true },
164
+ [WEB_PROVIDERS.EDGE]: { enabled: false },
165
+ [WEB_PROVIDERS.FIREFOX]: { enabled: false },
166
+ },
167
+ },
168
+ [ENVIRONMENTS.FAKE]: { enabled: false, targetId: null },
169
+ },
170
+ },
171
+ workspaces: {},
172
+ sessions: {},
173
+ });
174
+
175
+ function defaultEnvironmentEntry(environment) {
176
+ const entry = {
177
+ enabled: environment === ENVIRONMENTS.WEB,
178
+ targetId: null,
179
+ };
180
+ if (environment === ENVIRONMENTS.WEB) {
181
+ entry.provider = DEFAULT_WEB_PROVIDER;
182
+ entry.providers = defaultWebProviders();
183
+ }
184
+ return entry;
185
+ }
186
+
187
+ /** Merge provider maps per provider id: sibling providers are never replaced wholesale. */
188
+ function mergeProviderMaps(base, over) {
189
+ const out = {};
190
+ for (const [provider, entry] of Object.entries(base ?? {})) out[provider] = { ...entry };
191
+ for (const [provider, entry] of Object.entries(over ?? {})) {
192
+ out[provider] = { ...(out[provider] ?? {}), ...entry };
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * `allowPlanned: true` 只用于**启用**类写入(`environments.<id>.enabled`):
199
+ * v1 里 android / linux 等环境没有适配器,但允许用户先把开关打开——启用 ≠ 可执行,
200
+ * 可执行性由就绪六轴(adapter-not-registered / support=planned)如实表达。
201
+ * 选择"当前执行环境"仍然拒绝 planned(见 `environment` 的写入路径)。
202
+ */
203
+ function normalizeEnvironmentName(environment, field, { allowTestEnvironments = true, allowPlanned = false } = {}) {
204
+ if (typeof environment !== 'string' || environment.length === 0) {
205
+ throw invalid(`${field} must be a non-empty string`, { field });
206
+ }
207
+ const meta = KNOWN_ENVIRONMENTS[environment];
208
+ if (!meta) throw invalid(`unknown environment: ${environment}`, { field, environment });
209
+ if (meta.planned && !allowPlanned) {
210
+ throw invalid(`environment ${environment} is planned and cannot be selected in v1`, {
211
+ field,
212
+ environment,
213
+ });
214
+ }
215
+ if (meta.testOnly && !allowTestEnvironments) {
216
+ throw invalid(`environment ${environment} is test-only`, { field, environment });
217
+ }
218
+ return environment;
219
+ }
220
+
221
+ function normalizeEnabledEnvironments(list, field, options) {
222
+ if (!Array.isArray(list)) throw invalid(`${field} must be an array`, { field });
223
+ const out = [];
224
+ for (const item of list) {
225
+ const environment = normalizeEnvironmentName(item, field, options);
226
+ if (!out.includes(environment)) out.push(environment);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Validate a provider name.
233
+ *
234
+ * Write path (`strict`, i.e. every patch path): only chrome/edge/firefox are accepted, anything else
235
+ * is CONFIG_INVALID with the offending field — never a silent fallback.
236
+ *
237
+ * Load path (`strict:false`): a *well-typed but unknown* name (e.g. `"safari"` written by a newer
238
+ * version) is **kept verbatim**. Throwing here would make the store treat the whole file as corrupt,
239
+ * replace it with defaults and discard every saved switch — far worse than carrying one unusable
240
+ * selection. The snapshot then reports `source.rejectedProviderReason = 'unknown-provider'`, which
241
+ * blocks execution instead of quietly switching browsers. A value that is not a non-empty string is
242
+ * a structural error on both paths.
243
+ */
244
+ function normalizeWebProviderName(value, field, { allowNull = false, strict = true } = {}) {
245
+ if (allowNull && value === null) return null;
246
+ if (typeof value !== 'string' || value.length === 0) {
247
+ throw invalid(`unknown web provider: ${String(value)}`, {
248
+ field,
249
+ provider: value === undefined ? null : String(value),
250
+ allowed: [...WEB_PROVIDER_IDS],
251
+ });
252
+ }
253
+ if (WEB_PROVIDER_IDS.includes(value)) return value;
254
+ if (strict) {
255
+ throw invalid(`unknown web provider: ${value}`, {
256
+ field,
257
+ provider: value,
258
+ allowed: [...WEB_PROVIDER_IDS],
259
+ });
260
+ }
261
+ return value;
262
+ }
263
+
264
+ const PROVIDER_ENTRY_KEYS = new Set(['enabled', 'executable', 'connection']);
265
+
266
+ /**
267
+ * One provider entry. `partial` keeps the patch sparse (missing key = unchanged); `strict` rejects
268
+ * unknown keys instead of dropping them (write path).
269
+ */
270
+ function normalizeProviderEntry(raw, field, { partial = false, strict = false } = {}) {
271
+ const source = assertPlainObject(raw, field);
272
+ if (strict) {
273
+ for (const key of Object.keys(source)) {
274
+ if (!PROVIDER_ENTRY_KEYS.has(key)) {
275
+ throw invalid(`unknown provider patch key: ${field}.${key}`, { field: `${field}.${key}` });
276
+ }
277
+ }
278
+ }
279
+ const out = {};
280
+ if (source.enabled !== undefined) {
281
+ if (typeof source.enabled !== 'boolean') {
282
+ throw invalid(`${field}.enabled must be a boolean`, { field: `${field}.enabled` });
283
+ }
284
+ out.enabled = source.enabled;
285
+ }
286
+ if (source.executable !== undefined) {
287
+ if (source.executable !== null && (typeof source.executable !== 'string' || source.executable.length === 0)) {
288
+ throw invalid(`${field}.executable must be a non-empty string or null`, {
289
+ field: `${field}.executable`,
290
+ });
291
+ }
292
+ out.executable = source.executable;
293
+ }
294
+ if (source.connection !== undefined) {
295
+ out.connection = clone(assertPlainObject(source.connection, `${field}.connection`));
296
+ }
297
+ return out;
298
+ }
299
+
300
+ /** Provider sub-map: per-provider deep shape, unknown ids dropped (load) or rejected (patch). */
301
+ function normalizeProvidersMap(raw, field, { partial = false, strict = false } = {}) {
302
+ const source = assertPlainObject(raw, field);
303
+ const out = {};
304
+ for (const [provider, entry] of Object.entries(source)) {
305
+ if (!WEB_PROVIDER_IDS.includes(provider)) {
306
+ if (strict) {
307
+ throw invalid(`unknown web provider: ${provider}`, {
308
+ field: `${field}.${provider}`,
309
+ provider,
310
+ allowed: [...WEB_PROVIDER_IDS],
311
+ });
312
+ }
313
+ continue;
314
+ }
315
+ out[provider] = normalizeProviderEntry(entry, `${field}.${provider}`, { partial, strict });
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function normalizeEnvironmentEntry(raw, environment, { partial = false, strict = false } = {}) {
321
+ const field = `environments.${environment}`;
322
+ const source = assertPlainObject(raw, field);
323
+ const base = partial ? {} : defaultEnvironmentEntry(environment);
324
+ const out = { ...base };
325
+ if (source.enabled !== undefined) {
326
+ if (typeof source.enabled !== 'boolean') {
327
+ throw invalid(`${field}.enabled must be a boolean`, { field: `${field}.enabled` });
328
+ }
329
+ out.enabled = source.enabled;
330
+ }
331
+ if (source.targetId !== undefined) {
332
+ if (source.targetId !== null && typeof source.targetId !== 'string') {
333
+ throw invalid(`${field}.targetId must be a string or null`, { field: `${field}.targetId` });
334
+ }
335
+ out.targetId = source.targetId;
336
+ }
337
+ if (source.connection !== undefined) {
338
+ out.connection = clone(assertPlainObject(source.connection, `${field}.connection`));
339
+ }
340
+ const webOnly = environment === ENVIRONMENTS.WEB;
341
+ if (source.provider !== undefined) {
342
+ if (!webOnly) {
343
+ throw invalid(`${field}.provider is only supported for the web environment`, {
344
+ field: `${field}.provider`,
345
+ });
346
+ }
347
+ out.provider = normalizeWebProviderName(source.provider, `${field}.provider`, { strict });
348
+ }
349
+ if (source.providers !== undefined) {
350
+ if (!webOnly) {
351
+ throw invalid(`${field}.providers is only supported for the web environment`, {
352
+ field: `${field}.providers`,
353
+ });
354
+ }
355
+ const provided = normalizeProvidersMap(source.providers, `${field}.providers`, { partial, strict });
356
+ out.providers = mergeProviderMaps(out.providers, provided);
357
+ }
358
+ return out;
359
+ }
360
+
361
+ function normalizeEnvironmentMap(raw, field, { partial = false, strict = false } = {}) {
362
+ const source = assertPlainObject(raw, field);
363
+ const out = {};
364
+ for (const [environment, entry] of Object.entries(source)) {
365
+ // 未知环境丢弃(不猜);**planned 环境保留**——这一层是"每个环境的总开关",
366
+ // 「启用」不等于「可执行」:v1 里 android 等环境没有适配器,但用户可以先把它打开,
367
+ // 是否真的能跑由就绪六轴与"当前执行环境"的校验表达。
368
+ if (!Object.hasOwn(KNOWN_ENVIRONMENTS, environment)) continue;
369
+ out[environment] = normalizeEnvironmentEntry(entry, environment, { partial, strict });
370
+ }
371
+ return out;
372
+ }
373
+
374
+ function normalizeGlobal(raw) {
375
+ const source = assertPlainObject(raw, 'global');
376
+ const environments = {
377
+ [ENVIRONMENTS.WEB]: { ...defaultEnvironmentEntry(ENVIRONMENTS.WEB) },
378
+ [ENVIRONMENTS.FAKE]: { ...defaultEnvironmentEntry(ENVIRONMENTS.FAKE) },
379
+ };
380
+ const provided = normalizeEnvironmentMap(source.environments ?? {}, 'global.environments');
381
+ for (const [environment, entry] of Object.entries(provided)) {
382
+ environments[environment] = { ...environments[environment], ...entry };
383
+ }
384
+ return {
385
+ environment: source.environment === undefined
386
+ ? ENVIRONMENTS.WEB
387
+ : normalizeEnvironmentName(source.environment, 'global.environment'),
388
+ enabledEnvironments: source.enabledEnvironments === undefined
389
+ ? [ENVIRONMENTS.WEB]
390
+ : normalizeEnabledEnvironments(source.enabledEnvironments, 'global.enabledEnvironments'),
391
+ environments,
392
+ };
393
+ }
394
+
395
+ function normalizeWorkspaceEntry(raw, key) {
396
+ const source = assertPlainObject(raw, `workspaces.${key}`);
397
+ const out = {};
398
+ if (source.environment !== undefined) {
399
+ out.environment = normalizeEnvironmentName(source.environment, `workspaces.${key}.environment`);
400
+ }
401
+ if (source.enabledEnvironments !== undefined) {
402
+ out.enabledEnvironments = normalizeEnabledEnvironments(
403
+ source.enabledEnvironments,
404
+ `workspaces.${key}.enabledEnvironments`,
405
+ );
406
+ }
407
+ if (source.environments !== undefined) {
408
+ out.environments = normalizeEnvironmentMap(
409
+ source.environments,
410
+ `workspaces.${key}.environments`,
411
+ { partial: true },
412
+ );
413
+ }
414
+ if (source.targetId !== undefined) {
415
+ if (source.targetId !== null && typeof source.targetId !== 'string') {
416
+ throw invalid(`workspaces.${key}.targetId must be a string or null`, {
417
+ field: `workspaces.${key}.targetId`,
418
+ });
419
+ }
420
+ out.targetId = source.targetId;
421
+ }
422
+ return out;
423
+ }
424
+
425
+ function normalizeSessionEntry(raw, key) {
426
+ const source = assertPlainObject(raw, `sessions.${key}`);
427
+ const out = {};
428
+ if (source.environment !== undefined) {
429
+ out.environment = normalizeEnvironmentName(source.environment, `sessions.${key}.environment`);
430
+ }
431
+ if (source.targetId !== undefined) {
432
+ if (source.targetId !== null && typeof source.targetId !== 'string') {
433
+ throw invalid(`sessions.${key}.targetId must be a string or null`, {
434
+ field: `sessions.${key}.targetId`,
435
+ });
436
+ }
437
+ out.targetId = source.targetId;
438
+ }
439
+ if (source.parentSession !== undefined) {
440
+ if (source.parentSession !== null && typeof source.parentSession !== 'string') {
441
+ throw invalid(`sessions.${key}.parentSession must be a string or null`, {
442
+ field: `sessions.${key}.parentSession`,
443
+ });
444
+ }
445
+ out.parentSession = source.parentSession;
446
+ }
447
+ // Session-scoped provider selection (`null` clears it and re-inherits).
448
+ if (source.webProvider !== undefined) {
449
+ out.webProvider = normalizeWebProviderName(source.webProvider, `sessions.${key}.webProvider`, {
450
+ allowNull: true,
451
+ strict: false,
452
+ });
453
+ }
454
+ return out;
455
+ }
456
+
457
+ /**
458
+ * Fill defaults, drop unknown keys, and reject structurally invalid configs.
459
+ *
460
+ * @param {object|null|undefined} raw
461
+ * @returns {object} a fresh mutable config object (never the frozen default instance)
462
+ */
463
+ export function normalizeConfig(raw) {
464
+ if (raw === undefined || raw === null) return clone(DEFAULT_CONFIG);
465
+ const source = assertPlainObject(raw, 'config');
466
+ if (source.version !== undefined && source.version !== CONFIG_VERSION) {
467
+ throw invalid(`unsupported config version: ${String(source.version)}`, {
468
+ field: 'version',
469
+ version: source.version,
470
+ });
471
+ }
472
+ const workspaces = {};
473
+ const rawWorkspaces = source.workspaces === undefined
474
+ ? {}
475
+ : assertPlainObject(source.workspaces, 'workspaces');
476
+ for (const [key, entry] of Object.entries(rawWorkspaces)) {
477
+ const normalized = normalizeWorkspaceEntry(entry, key);
478
+ if (Object.keys(normalized).length > 0) workspaces[key] = normalized;
479
+ }
480
+
481
+ const sessions = {};
482
+ const rawSessions = source.sessions === undefined
483
+ ? {}
484
+ : assertPlainObject(source.sessions, 'sessions');
485
+ for (const [key, entry] of Object.entries(rawSessions)) {
486
+ const normalized = normalizeSessionEntry(entry, key);
487
+ if (Object.keys(normalized).length > 0) sessions[key] = normalized;
488
+ }
489
+
490
+ return {
491
+ version: CONFIG_VERSION,
492
+ revision: source.revision === undefined ? 0 : assertRevision(source.revision),
493
+ global: normalizeGlobal(source.global ?? {}),
494
+ workspaces,
495
+ sessions,
496
+ };
497
+ }
498
+
499
+ const GLOBAL_PATCH_KEYS = new Set(['environment', 'enabledEnvironments', 'environments', 'targetId']);
500
+ const WORKSPACE_PATCH_KEYS = new Set(['environment', 'enabledEnvironments', 'environments', 'targetId']);
501
+ const SESSION_PATCH_KEYS = new Set(['environment', 'targetId', 'webProvider']);
502
+ const ENVIRONMENT_ENTRY_KEYS = new Set(['enabled', 'targetId', 'connection', 'provider', 'providers']);
503
+
504
+ function normalizePatchEnvironments(raw, field, options) {
505
+ const source = assertPlainObject(raw, field);
506
+ const out = {};
507
+ for (const [environment, entry] of Object.entries(source)) {
508
+ // 这里是"每个环境的总开关",不是"当前执行环境":planned 环境允许被启用。
509
+ normalizeEnvironmentName(environment, field, { ...(options || {}), allowPlanned: true });
510
+ const entrySource = assertPlainObject(entry, `${field}.${environment}`);
511
+ for (const key of Object.keys(entrySource)) {
512
+ if (!ENVIRONMENT_ENTRY_KEYS.has(key)) {
513
+ throw invalid(`unknown environment patch key: ${field}.${environment}.${key}`, {
514
+ field: `${field}.${environment}.${key}`,
515
+ });
516
+ }
517
+ }
518
+ out[environment] = normalizeEnvironmentEntry(entrySource, environment, { partial: true, strict: true });
519
+ }
520
+ return out;
521
+ }
522
+
523
+ /**
524
+ * Validate and normalize a write patch.
525
+ *
526
+ * @param {{scope:'global'|'workspace'|'session', patch:object}} input
527
+ * @param {{allowTestEnvironments?:boolean}} [options]
528
+ * @returns {object} the normalized patch (unknown keys rejected, not silently dropped)
529
+ */
530
+ export function validatePatch({ scope, patch } = {}, { allowTestEnvironments = false } = {}) {
531
+ if (scope !== 'global' && scope !== 'workspace' && scope !== 'session') {
532
+ throw invalid('patch scope must be global, workspace or session', { scope: String(scope) });
533
+ }
534
+ const source = assertPlainObject(patch, 'patch');
535
+ const keys = Object.keys(source);
536
+ if (keys.length === 0) throw invalid('patch must not be empty', { scope });
537
+
538
+ const allowed = scope === 'session' ? SESSION_PATCH_KEYS : (scope === 'global' ? GLOBAL_PATCH_KEYS : WORKSPACE_PATCH_KEYS);
539
+ for (const key of keys) {
540
+ if (!allowed.has(key)) throw invalid(`unknown ${scope} patch key: ${key}`, { scope, key });
541
+ }
542
+
543
+ const options = { allowTestEnvironments };
544
+ const out = {};
545
+ if (source.environment !== undefined) {
546
+ out.environment = normalizeEnvironmentName(source.environment, `${scope}.environment`, options);
547
+ }
548
+ if (source.enabledEnvironments !== undefined) {
549
+ out.enabledEnvironments = normalizeEnabledEnvironments(
550
+ source.enabledEnvironments,
551
+ `${scope}.enabledEnvironments`,
552
+ options,
553
+ );
554
+ }
555
+ if (source.environments !== undefined) {
556
+ out.environments = normalizePatchEnvironments(source.environments, `${scope}.environments`, options);
557
+ }
558
+ if (source.targetId !== undefined) {
559
+ if (source.targetId !== null && typeof source.targetId !== 'string') {
560
+ throw invalid(`${scope}.targetId must be a string or null`, { scope, field: 'targetId' });
561
+ }
562
+ out.targetId = source.targetId;
563
+ }
564
+ if (source.webProvider !== undefined) {
565
+ out.webProvider = normalizeWebProviderName(source.webProvider, `${scope}.webProvider`, {
566
+ allowNull: true,
567
+ });
568
+ }
569
+ return out;
570
+ }
571
+
572
+ /**
573
+ * Stable workspace key shared with runtime entries: `workspace:<id>` or `path:<sha256>`.
574
+ * Legacy alias for `workspaceKeyOf({cwd})`; returns null for a missing cwd.
575
+ */
576
+ export function workspaceKeyForCwd(cwd) {
577
+ return workspaceKeyOf({ cwd });
578
+ }
579
+
580
+ /** Capability catalog for one environment; unknown/planned environments expose nothing. */
581
+ export function capabilitiesForEnvironment(environment) {
582
+ const list = ENVIRONMENT_CAPABILITIES[environment];
583
+ return Array.isArray(list) ? [...list] : [];
584
+ }
585
+
586
+ function agentSessionId(agent) {
587
+ const candidate = agent?.id ?? agent?.sessionId;
588
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
589
+ }
590
+
591
+ /**
592
+ * Resolve the effective config snapshot for one agent/workspace.
593
+ *
594
+ * Frozen total order: session own entry > parent's effective snapshot (following
595
+ * `agent.session.header.parentSession`, depth <= 8, cycle-safe) > workspace > global. The parent
596
+ * contributes its *effective* environment/target (already filtered by enabledEnvironments), not
597
+ * its raw config entry.
598
+ *
599
+ * A selection outside `enabledEnvironments` is rejected and falls back to `global.environment`;
600
+ * the rejected value is reported in `source.rejectedEnvironment` (never silently allowed).
601
+ *
602
+ * Web *provider* selection is an independent chain over the same scopes:
603
+ * `session.webProvider > parent's effective provider > workspace.environments.web.provider >
604
+ * global.environments.web.provider > "chrome"` (last step only for legacy configs without the
605
+ * field). A disabled provider is **not** silently replaced: the selection is kept in
606
+ * `snapshot.provider`, and `source.rejectedProvider` + `source.rejectedProviderReason` report that
607
+ * it cannot execute (the execution layer turns that into ADAPTER_DISABLED). `firefox` is a legal
608
+ * provider name that may be enabled, but has no driver: `providerConfig.implemented` is false and
609
+ * the execution layer must refuse with ADAPTER_UNSUPPORTED. A name that is not a known provider at
610
+ * all (`'safari'`, loaded from a newer config file) is preserved and reported as
611
+ * `rejectedProviderReason: 'unknown-provider'` — never replaced by chrome.
612
+ *
613
+ * Malformed config degrades to defaults with `source.degraded=true` + `source.degradedReason`
614
+ * instead of throwing. Only explicit invalid patches throw CONFIG_INVALID (validatePatch).
615
+ *
616
+ * @param {object} config
617
+ * @param {{agent?:object, workspaceKey?:string|null}} [input]
618
+ * @param {{allowTestEnvironments?:boolean}} [options]
619
+ * @returns {{revision:number, environment:string, adapterId:string, provider:string|null,
620
+ * executionAdapterId:string, enabledProviders:string[], providerConfig:object|null,
621
+ * enabledEnvironments:string[], capabilities:string[], environmentConfig:object,
622
+ * targetId:string|null, source:{globalRevision:number, workspaceKey:string|null,
623
+ * sessionId:string|null, inheritedFrom:string|null, rejectedEnvironment:string|null,
624
+ * providerSource:string, inheritedProviderFrom:string|null, rejectedProvider:string|null,
625
+ * rejectedProviderReason:string|null, degraded:boolean, degradedReason:string|null}}}
626
+ */
627
+ export function resolveSnapshot(config, { agent = null, workspaceKey = null } = {}, { allowTestEnvironments = false } = {}) {
628
+ let normalized;
629
+ let degraded = false;
630
+ let degradedReason = null;
631
+ try {
632
+ normalized = normalizeConfig(config);
633
+ } catch (error) {
634
+ degraded = true;
635
+ degradedReason = error instanceof AdapterError ? error.message : String(error?.message ?? error);
636
+ normalized = normalizeConfig(DEFAULT_CONFIG);
637
+ }
638
+
639
+ const sessionId = agentSessionId(agent);
640
+ const key = typeof workspaceKey === 'string' && workspaceKey.length > 0 ? workspaceKey : null;
641
+ const workspace = key && Object.hasOwn(normalized.workspaces, key) ? normalized.workspaces[key] : null;
642
+
643
+ const enabledSource = workspace?.enabledEnvironments ?? normalized.global.enabledEnvironments;
644
+ const enabledEnvironments = enabledSource.filter((environment) => {
645
+ const meta = KNOWN_ENVIRONMENTS[environment];
646
+ if (!meta || meta.planned) return false;
647
+ if (meta.testOnly && !allowTestEnvironments) return false;
648
+ return true;
649
+ });
650
+ const enabledSet = new Set(enabledEnvironments);
651
+
652
+ const selectable = (environment) => {
653
+ const meta = KNOWN_ENVIRONMENTS[environment];
654
+ if (!meta || meta.planned) return false;
655
+ if (meta.testOnly && !allowTestEnvironments) return false;
656
+ return true;
657
+ };
658
+
659
+ const applyEnabled = (environment) => {
660
+ if (selectable(environment) && enabledSet.has(environment)) {
661
+ return { environment, rejected: null };
662
+ }
663
+ let fallback;
664
+ if (enabledSet.has(normalized.global.environment)) fallback = normalized.global.environment;
665
+ else if (enabledEnvironments.length > 0) fallback = enabledEnvironments[0];
666
+ else fallback = ENVIRONMENTS.WEB;
667
+ return { environment: fallback, rejected: environment };
668
+ };
669
+
670
+ const workspaceTarget = (environment) => {
671
+ const entryTarget = workspace?.environments?.[environment]?.targetId;
672
+ if (entryTarget !== undefined) return entryTarget;
673
+ if (workspace?.targetId !== undefined && workspace.environment === environment) {
674
+ return workspace.targetId;
675
+ }
676
+ return undefined;
677
+ };
678
+ const globalTarget = (environment) => normalized.global.environments[environment]?.targetId;
679
+
680
+ // `provider` is web-only: workspace/global entries for other environments have no such key.
681
+ const workspaceProvider = () => workspace?.environments?.[ENVIRONMENTS.WEB]?.provider;
682
+ const globalProvider = () => normalized.global.environments?.[ENVIRONMENTS.WEB]?.provider;
683
+
684
+ const selectForSession = (currentId, agentParent, depth, seen) => {
685
+ const own = Object.hasOwn(normalized.sessions, currentId) ? normalized.sessions[currentId] : null;
686
+ const ownParent = isPlainObject(own)
687
+ && typeof own.parentSession === 'string'
688
+ && own.parentSession.length > 0
689
+ ? own.parentSession
690
+ : null;
691
+ const parentId = depth === 0 ? (agentParent ?? ownParent) : ownParent;
692
+ let parent = null;
693
+ if (parentId !== null && depth < 8 && !seen.has(parentId)) {
694
+ const nextSeen = new Set(seen);
695
+ nextSeen.add(parentId);
696
+ parent = selectForSession(parentId, null, depth + 1, nextSeen);
697
+ }
698
+
699
+ let environment;
700
+ let environmentSource;
701
+ if (isPlainObject(own) && own.environment !== undefined) {
702
+ environment = own.environment;
703
+ environmentSource = currentId;
704
+ } else if (parent !== null) {
705
+ environment = parent.environment;
706
+ environmentSource = parent.environmentSource;
707
+ } else if (workspace?.environment !== undefined) {
708
+ environment = workspace.environment;
709
+ environmentSource = 'workspace';
710
+ } else {
711
+ environment = normalized.global.environment;
712
+ environmentSource = 'global';
713
+ }
714
+
715
+ // Independent provider chain: own webProvider > parent's effective provider > workspace >
716
+ // global > "chrome". Normalization already materializes chrome for a legacy config, so the last
717
+ // step is a defensive default rather than a normally observable source.
718
+ let provider;
719
+ let providerSource;
720
+ if (isPlainObject(own) && own.webProvider !== undefined && own.webProvider !== null) {
721
+ provider = own.webProvider;
722
+ providerSource = currentId;
723
+ } else if (parent !== null) {
724
+ provider = parent.provider;
725
+ providerSource = parent.providerSource;
726
+ } else if (workspaceProvider() !== undefined) {
727
+ provider = workspaceProvider();
728
+ providerSource = 'workspace';
729
+ } else if (globalProvider() !== undefined) {
730
+ provider = globalProvider();
731
+ providerSource = 'global';
732
+ } else {
733
+ provider = DEFAULT_WEB_PROVIDER;
734
+ providerSource = 'default';
735
+ }
736
+
737
+ const effective = applyEnabled(environment);
738
+ const rejectedEnvironment = effective.rejected !== null
739
+ ? effective.rejected
740
+ : (isPlainObject(own) && own.environment !== undefined ? null : (parent?.rejectedEnvironment ?? null));
741
+
742
+ let targetId;
743
+ const ownEnvironmentApplied = !(isPlainObject(own) && own.environment !== undefined)
744
+ || own.environment === effective.environment;
745
+ if (isPlainObject(own) && own.targetId !== undefined && ownEnvironmentApplied) {
746
+ targetId = own.targetId;
747
+ } else if (parent !== null && parent.environment === effective.environment) {
748
+ targetId = parent.targetId;
749
+ } else {
750
+ const workspaceValue = workspaceTarget(effective.environment);
751
+ if (workspaceValue !== undefined) {
752
+ targetId = workspaceValue;
753
+ } else {
754
+ const globalValue = globalTarget(effective.environment);
755
+ targetId = globalValue !== undefined ? globalValue : null;
756
+ }
757
+ }
758
+
759
+ return {
760
+ environment: effective.environment,
761
+ environmentSource,
762
+ rejectedEnvironment,
763
+ provider,
764
+ providerSource,
765
+ targetId,
766
+ };
767
+ };
768
+
769
+ let selection;
770
+ if (sessionId !== null) {
771
+ const agentParent = typeof agent?.session?.header?.parentSession === 'string'
772
+ && agent.session.header.parentSession.length > 0
773
+ ? agent.session.header.parentSession
774
+ : null;
775
+ selection = selectForSession(sessionId, agentParent, 0, new Set([sessionId]));
776
+ } else {
777
+ const environment = workspace?.environment ?? normalized.global.environment;
778
+ const effective = applyEnabled(environment);
779
+ const workspaceValue = workspaceTarget(effective.environment);
780
+ const globalValue = globalTarget(effective.environment);
781
+ let provider;
782
+ let providerSource;
783
+ if (workspaceProvider() !== undefined) {
784
+ provider = workspaceProvider();
785
+ providerSource = 'workspace';
786
+ } else if (globalProvider() !== undefined) {
787
+ provider = globalProvider();
788
+ providerSource = 'global';
789
+ } else {
790
+ provider = DEFAULT_WEB_PROVIDER;
791
+ providerSource = 'default';
792
+ }
793
+ selection = {
794
+ environment: effective.environment,
795
+ environmentSource: workspace?.environment !== undefined ? 'workspace' : 'global',
796
+ rejectedEnvironment: effective.rejected,
797
+ provider,
798
+ providerSource,
799
+ targetId: workspaceValue !== undefined ? workspaceValue : (globalValue !== undefined ? globalValue : null),
800
+ };
801
+ }
802
+
803
+ const environment = selection.environment;
804
+ const globalEnvironmentConfig = normalized.global.environments[environment] ?? {};
805
+ const workspaceEnvironmentConfig = workspace?.environments?.[environment] ?? {};
806
+ const environmentConfig = { ...globalEnvironmentConfig, ...workspaceEnvironmentConfig };
807
+ if (globalEnvironmentConfig.connection !== undefined || workspaceEnvironmentConfig.connection !== undefined) {
808
+ environmentConfig.connection = {
809
+ ...(globalEnvironmentConfig.connection ?? {}),
810
+ ...(workspaceEnvironmentConfig.connection ?? {}),
811
+ };
812
+ }
813
+ if (globalEnvironmentConfig.providers !== undefined || workspaceEnvironmentConfig.providers !== undefined) {
814
+ environmentConfig.providers = mergeProviderMaps(
815
+ globalEnvironmentConfig.providers,
816
+ workspaceEnvironmentConfig.providers,
817
+ );
818
+ }
819
+
820
+ const inheritedFrom = sessionId !== null && selection.environmentSource === sessionId
821
+ ? null
822
+ : selection.environmentSource;
823
+ const inheritedProviderFrom = sessionId !== null && selection.providerSource === sessionId
824
+ ? null
825
+ : selection.providerSource;
826
+
827
+ // Web provider facts. `enabledProviders` is always derived from `providers.*.enabled` (no second
828
+ // stored list); `provider` / `executionAdapterId` / `providerConfig` only exist for web.
829
+ const enabledProviders = WEB_PROVIDER_IDS.filter(
830
+ (id) => environmentConfig.providers?.[id]?.enabled === true,
831
+ );
832
+ const isWeb = environment === ENVIRONMENTS.WEB;
833
+ const provider = isWeb ? selection.provider : null;
834
+ const providerEntry = isWeb ? (environmentConfig.providers?.[provider] ?? {}) : {};
835
+ // Precedence: total gate -> unknown provider name -> per-provider switch. Every non-null value
836
+ // blocks execution; an unknown name is kept so nothing is silently swapped for chrome.
837
+ const providerKnown = provider !== null && WEB_PROVIDER_IDS.includes(provider);
838
+ let rejectedProviderReason = null;
839
+ if (isWeb) {
840
+ if (environmentConfig.enabled !== true) rejectedProviderReason = 'environment-disabled';
841
+ else if (!providerKnown) rejectedProviderReason = 'unknown-provider';
842
+ else if (providerEntry.enabled !== true) rejectedProviderReason = 'provider-disabled';
843
+ }
844
+ // Legacy env-level `connection` is a chrome-scoped compatibility field (§11): it is reported for
845
+ // chrome only and never inherited by another provider.
846
+ const legacyConnection = environmentConfig.connection ?? null;
847
+ const providerConfig = isWeb
848
+ ? {
849
+ provider,
850
+ // Canonical engineering support fact; `implemented` is its derived boolean, never a second truth.
851
+ support: webProviderSupport(provider),
852
+ enabled: providerEntry.enabled === true,
853
+ executable: providerEntry.executable ?? null,
854
+ connection: providerEntry.connection
855
+ ?? (provider === WEB_PROVIDERS.CHROME ? legacyConnection : null),
856
+ implemented: isImplementedWebProvider(provider),
857
+ }
858
+ : null;
859
+
860
+ // A snapshot is a *step input*: it must be immutable, because admission, schema declaration,
861
+ // Skill discovery and execution all read the same object (A11). Freezing here — not at each
862
+ // call site — makes "one step, one version" enforceable instead of a convention.
863
+ return deepFreeze({
864
+ revision: normalized.revision,
865
+ environment,
866
+ adapterId: environment,
867
+ provider,
868
+ executionAdapterId: isWeb ? `${environment}-${provider}` : environment,
869
+ enabledProviders,
870
+ providerConfig,
871
+ enabledEnvironments: [...enabledEnvironments],
872
+ capabilities: capabilitiesForEnvironment(environment),
873
+ environmentConfig,
874
+ // Legacy `targetId` belongs to the chrome runtime: it must not leak into another provider.
875
+ targetId: isWeb && provider !== WEB_PROVIDERS.CHROME ? null : (selection.targetId ?? null),
876
+ source: {
877
+ globalRevision: normalized.revision,
878
+ workspaceKey: key,
879
+ sessionId,
880
+ inheritedFrom,
881
+ rejectedEnvironment: selection.rejectedEnvironment,
882
+ providerSource: selection.providerSource,
883
+ inheritedProviderFrom,
884
+ rejectedProvider: rejectedProviderReason === null ? null : provider,
885
+ rejectedProviderReason,
886
+ degraded,
887
+ degradedReason,
888
+ },
889
+ });
890
+ }
891
+
892
+ export { CONTRACT_VERSION as CONFIG_CONTRACT_VERSION };