dsh-agent-team-ui-testing 0.1.1 → 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -35
  3. package/annotation-capture.js +111 -0
  4. package/browser-safety.js +45 -1
  5. package/client.js +3709 -134
  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/index.md +10 -7
  28. package/docs/multi-tab.md +55 -20
  29. package/docs/network-p0.md +2 -2
  30. package/docs/panel-context-menu.md +41 -0
  31. package/docs/t2-structured-gui.md +2 -2
  32. package/docs/t3-vision.md +6 -1
  33. package/docs/t4-dynamic-behavior.md +1 -0
  34. package/docs/tools.md +65 -7
  35. package/docs/verification.md +26 -2
  36. package/docs/viewport-control.md +21 -8
  37. package/index.js +3263 -251
  38. package/integration/cross-environment.js +1535 -0
  39. package/integration/diagnose.js +945 -0
  40. package/integration/tab-principal.js +493 -0
  41. package/locale/en.json +1 -1
  42. package/locale/zh.json +1 -1
  43. package/network-p0.js +24 -7
  44. package/package.json +4 -3
  45. package/select-control.js +298 -0
  46. package/skills/ui-testing-common/SKILL.md +54 -0
  47. package/skills/ui-testing-fake/SKILL.md +43 -0
  48. package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
  49. package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
  50. package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
  51. package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
  52. package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
  53. package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
  54. package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
  55. package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
  56. package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
  57. package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
  58. package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
  59. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  60. package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
  61. package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
  62. package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
  63. package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
  64. package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
  65. package/targets/fake/adapter.js +1163 -0
  66. package/targets/fake/fixtures.js +177 -0
  67. package/targets/web/adapter.js +1231 -0
  68. package/targets/web/provider.js +415 -0
  69. package/skills/ui-testing/references/scenarios/annotations.md +0 -87
  70. /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
  71. /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
  72. /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
  73. /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
  74. /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
  75. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
  76. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
  77. /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
  78. /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
  79. /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
  80. /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
  81. /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
  82. /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
  83. /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Trusted identity resolver: turns one `exec.agent` into a Principal, or refuses (M1).
3
+ *
4
+ * Why this file is separate from `core/tab-access.js`
5
+ * - `core/` answers "given a proven Principal, may this happen?". This file answers "may we believe
6
+ * this is who it claims to be?" — and that question can only be answered with native Host
7
+ * services (the Agent registry and the Team roster). Keeping the two apart means the permission
8
+ * rules are testable with zero Host, and the Host-dependent part has exactly one entry point.
9
+ *
10
+ * The three things this module must never do
11
+ * 1. **Never invent a root.** The root comes from `membership.root.id`. Not from cwd, not from the
12
+ * workspace, not from `session.header.parentSession`, not from "the deepest ancestor I can
13
+ * find". A guessed root is a guessed permission domain.
14
+ * 2. **Never degrade `undefined` into `standalone`.** `tryMembership === undefined` means "this
15
+ * probe could not prove a team" (stale object, non-Team worker, root briefly absent, caught
16
+ * malformed stream) — it does *not* prove "not in a team". Downgrading it would let a stale
17
+ * object found a private team of one and keep its pages; the rule is TAB_IDENTITY_UNAVAILABLE.
18
+ * A standalone domain is only ever granted when the host hands over an explicit proof.
19
+ * 3. **Never call `ctx.get` itself.** Every native service arrives through the injected seam
20
+ * (`{agentRegistry, teamService, standaloneProof}`), so the host composition root decides what
21
+ * identity means and tests can drive the matrix in-process without a Cordis context.
22
+ *
23
+ * Epoch bookkeeping (ruling §2.2/§7.1) — all versions here are *plugin* versions, never claimed to
24
+ * be native host revisions:
25
+ * - `executionEpoch` is per exact agent *object*. Registering a new object (including a
26
+ * same-Session resume) bumps it; the previous object is revoked forever. A re-used epoch would
27
+ * let a tail request from the dead instance run against the resumed one.
28
+ * - `membershipRevision` moves only when the trusted identity state actually changes: a different
29
+ * root, a different role, a different roster fingerprint, a different service instance, or an
30
+ * identity that was invalidated and came back. `running`/`inactive` never move it: a teammate
31
+ * thinking, or a turn ending, is not a membership change (plan §3.4 invariant 1).
32
+ * - `hostEpoch` is one random id per plugin boot, so nothing minted before a restart can be
33
+ * compared as current afterwards.
34
+ */
35
+
36
+ import { randomUUID } from 'node:crypto';
37
+
38
+ import {
39
+ AdapterError,
40
+ ERROR_CODES,
41
+ PRINCIPAL_ROLE,
42
+ PRINCIPAL_SOURCE,
43
+ makePrincipal,
44
+ } from '../core/index.js';
45
+
46
+ /** Roster row statuses that mean "this Session is not (yet) an acting member". */
47
+ const NON_MEMBER_STATUSES = new Set(['failed', 'provisioning']);
48
+
49
+ function isPlainObject(value) {
50
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ function isNonEmptyString(value) {
54
+ return typeof value === 'string' && value.trim().length > 0;
55
+ }
56
+
57
+ /**
58
+ * One refusal code for every unprovable identity.
59
+ *
60
+ * They all collapse into TAB_IDENTITY_UNAVAILABLE on purpose: distinguishing "the object was stale"
61
+ * from "the roster does not list you" from "the root is not loaded" in the *public* code would tell
62
+ * a caller things about another Session's lifecycle. The reason stays in `details` for logs.
63
+ */
64
+ function identityUnavailable(reason, details = {}) {
65
+ return new AdapterError(ERROR_CODES.TAB_IDENTITY_UNAVAILABLE, `identity unavailable: ${reason}`, {
66
+ details: Object.freeze({ reason, ...details }),
67
+ });
68
+ }
69
+
70
+ function readNow(now) {
71
+ if (typeof now === 'function') return now();
72
+ if (typeof now === 'number' && Number.isFinite(now)) return now;
73
+ return Date.now();
74
+ }
75
+
76
+ /**
77
+ * Normalize one roster row to "is this a currently valid member?".
78
+ *
79
+ * `running` and `inactive` both mean member: turn availability is orthogonal to membership, and
80
+ * treating "inactive" as "left the team" is the exact bug the plan forbids (an inactive teammate
81
+ * can still be handed responsibility by the Lead). `failed`/`provisioning` are excluded — one never
82
+ * ran, the other broke — and any future status is treated as a member so a host that adds a new
83
+ * runtime state does not silently evict everyone.
84
+ *
85
+ * @param {{id?:string, role?:string, status?:string}} row
86
+ * @returns {boolean}
87
+ */
88
+ export function isValidRosterRow(row) {
89
+ if (!isPlainObject(row) || !isNonEmptyString(row.id)) return false;
90
+ if (typeof row.status === 'string' && NON_MEMBER_STATUSES.has(row.status)) return false;
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Fingerprint of a roster: the sorted `(SessionId, role)` set of valid rows.
96
+ *
97
+ * This is what makes "did the membership actually change?" answerable without a native revision
98
+ * (the host exposes none). Sorted, so row order is not a change; turn activity is excluded, so a
99
+ * busy team is not a changed team.
100
+ *
101
+ * @param {Array<object>} rows
102
+ * @returns {string}
103
+ */
104
+ export function rosterFingerprint(rows) {
105
+ if (!Array.isArray(rows)) return 'none';
106
+ const parts = rows
107
+ .filter(isValidRosterRow)
108
+ .map((row) => `${row.id}|${typeof row.role === 'string' ? row.role : 'unknown'}`)
109
+ .sort();
110
+ return parts.length === 0 ? 'empty' : parts.join(',');
111
+ }
112
+
113
+ /**
114
+ * Identity registry: the stateful half of the resolver.
115
+ *
116
+ * One instance per plugin boot (the default instance below is that one). `hostEpoch` is random, so
117
+ * two boots never produce comparable versions.
118
+ */
119
+ export class TabPrincipalResolver {
120
+ /**
121
+ * @param {{hostEpoch?:string, now?:number|Function}} [options]
122
+ */
123
+ constructor({ hostEpoch = randomUUID(), now } = {}) {
124
+ if (!isNonEmptyString(hostEpoch)) {
125
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'hostEpoch must be a non-empty string');
126
+ }
127
+ this.hostEpoch = hostEpoch;
128
+ this.now = typeof now === 'function' ? now : () => readNow(now);
129
+ /** @type {WeakMap<object, object>} exact agent object -> frozen record snapshot */
130
+ this.records = new WeakMap();
131
+ /** @type {WeakSet<object>} agent objects that must never resolve again */
132
+ this.revoked = new WeakSet();
133
+ /** @type {Map<string, object>} actorId -> per-Session bookkeeping (survives object replacement) */
134
+ this.actors = new Map();
135
+ }
136
+
137
+ /**
138
+ * Per-actor bookkeeping, created on first sight.
139
+ * @param {string} actorId
140
+ */
141
+ actorState(actorId) {
142
+ let state = this.actors.get(actorId);
143
+ if (state === undefined) {
144
+ state = {
145
+ actorId,
146
+ executionEpoch: 0,
147
+ membershipRevision: 0,
148
+ rootId: null,
149
+ role: null,
150
+ fingerprint: null,
151
+ agentRegistry: null,
152
+ teamService: null,
153
+ activeAgent: null,
154
+ invalidated: false,
155
+ };
156
+ this.actors.set(actorId, state);
157
+ }
158
+ return state;
159
+ }
160
+
161
+ /**
162
+ * Permanent invalidation hook for `agent/disposed` (or any teardown the host knows about).
163
+ *
164
+ * The host does not provide a "session permanently deleted" event (ruling §3), so this only ever
165
+ * invalidates the *instance*: the root and the page ownership stay where they are, waiting for a
166
+ * legitimate recovery or an explicit close.
167
+ *
168
+ * @param {object} agent exact agent object
169
+ * @returns {boolean} true when the object was known to this resolver
170
+ */
171
+ invalidate(agent) {
172
+ if (!isPlainObject(agent) || !isNonEmptyString(agent.id)) return false;
173
+ const state = this.actors.get(agent.id);
174
+ this.revoked.add(agent);
175
+ if (state === undefined) return false;
176
+ if (state.activeAgent === agent) {
177
+ state.activeAgent = null;
178
+ // Coming back (a resume with a fresh object) is a trusted-identity state change, so the next
179
+ // resolution bumps membershipRevision and every observation made before is honestly stale.
180
+ state.invalidated = true;
181
+ }
182
+ return true;
183
+ }
184
+
185
+ /**
186
+ * Resolve one exact agent into a Principal.
187
+ *
188
+ * @param {object} agent `exec.agent` — the only credential. It is never read from arguments,
189
+ * headers, query strings or `sessionId` parameters.
190
+ * @param {{agentRegistry?:object, teamService?:object, now?:number|Function,
191
+ * standaloneProof?:Function}} deps injected native services
192
+ * @returns {Readonly<object>} branded Principal
193
+ * @throws {AdapterError} TAB_IDENTITY_UNAVAILABLE when any step cannot be proven
194
+ */
195
+ resolve(agent, deps = {}) {
196
+ const { agentRegistry, teamService, standaloneProof } = deps;
197
+ const now = readNow(deps.now ?? this.now);
198
+
199
+ if (!isPlainObject(agent) || !isNonEmptyString(agent.id)) {
200
+ throw identityUnavailable('agent-identity-missing');
201
+ }
202
+ const actorId = agent.id;
203
+ if (this.revoked.has(agent)) {
204
+ // The object was disposed, replaced, or superseded. It never becomes valid again.
205
+ throw identityUnavailable('actor-instance-revoked', { actorSessionId: actorId });
206
+ }
207
+
208
+ // 1. Exact object check. `get(id)` returning a *different* object is the normal shape of a
209
+ // stale reference; `get(id)` returning undefined means disposed. Both are refusals, never a
210
+ // "close enough" match on the id string.
211
+ if (!isPlainObject(agentRegistry) || typeof agentRegistry.get !== 'function') {
212
+ throw identityUnavailable('agent-registry-unavailable', { actorSessionId: actorId });
213
+ }
214
+ let registered;
215
+ try {
216
+ registered = agentRegistry.get(actorId);
217
+ } catch (cause) {
218
+ throw new AdapterError(ERROR_CODES.TAB_IDENTITY_UNAVAILABLE, 'identity unavailable: agent-registry-failed', {
219
+ details: { reason: 'agent-registry-failed', actorSessionId: actorId },
220
+ cause,
221
+ });
222
+ }
223
+ if (registered === undefined) {
224
+ throw identityUnavailable('agent-not-registered', { actorSessionId: actorId });
225
+ }
226
+ if (registered !== agent) {
227
+ throw identityUnavailable('agent-object-mismatch', { actorSessionId: actorId });
228
+ }
229
+
230
+ // 2. Native membership. `tryMembership` is the non-throwing probe the host documents for
231
+ // lifecycle observers; `membership` throws TeamError and is the fallback.
232
+ const membership = this.readMembership(teamService, agent);
233
+ let rootId;
234
+ let role;
235
+ let fingerprint;
236
+ let rows = null;
237
+
238
+ if (membership === undefined) {
239
+ // No native membership proof. The only acceptable alternative is an explicit host-provided
240
+ // standalone proof; otherwise this is TAB_IDENTITY_UNAVAILABLE, not a new domain.
241
+ const proof = this.readStandaloneProof(standaloneProof, agent, { agentRegistry, teamService });
242
+ if (proof === null) {
243
+ throw identityUnavailable('membership-undefined', { actorSessionId: actorId });
244
+ }
245
+ rootId = actorId;
246
+ role = PRINCIPAL_ROLE.STANDALONE;
247
+ fingerprint = 'standalone';
248
+ } else {
249
+ if (!isPlainObject(membership) || !isPlainObject(membership.root)) {
250
+ throw identityUnavailable('membership-malformed', { actorSessionId: actorId });
251
+ }
252
+ if (membership.role !== PRINCIPAL_ROLE.LEAD && membership.role !== PRINCIPAL_ROLE.TEAMMATE) {
253
+ throw identityUnavailable('membership-role-unknown', {
254
+ actorSessionId: actorId,
255
+ role: typeof membership.role === 'string' ? membership.role : null,
256
+ });
257
+ }
258
+ // 3. The returned root must itself still be the exact registry object. A root that is only
259
+ // equal by id is a stale or rebuilt object, and permissions derived from it would outlive
260
+ // the instance that granted them.
261
+ if (!isNonEmptyString(membership.root.id)) {
262
+ throw identityUnavailable('membership-root-missing', { actorSessionId: actorId });
263
+ }
264
+ if (agentRegistry.get(membership.root.id) !== membership.root) {
265
+ throw identityUnavailable('root-object-mismatch', {
266
+ actorSessionId: actorId,
267
+ rootSessionId: membership.root.id,
268
+ });
269
+ }
270
+ rootId = membership.root.id;
271
+ role = membership.role;
272
+
273
+ // 4. A teammate additionally needs a *fresh* roster row. Being named in a stale membership is
274
+ // not enough: a failed/provisioning/removed row must not keep page rights.
275
+ if (role === PRINCIPAL_ROLE.TEAMMATE) {
276
+ rows = this.readRoster(teamService, membership.root, { actorSessionId: actorId, rootSessionId: rootId });
277
+ const row = rows.find((candidate) => isPlainObject(candidate) && candidate.id === actorId);
278
+ if (row === undefined) {
279
+ throw identityUnavailable('teammate-not-in-roster', {
280
+ actorSessionId: actorId,
281
+ rootSessionId: rootId,
282
+ });
283
+ }
284
+ if (!isValidRosterRow(row)) {
285
+ throw identityUnavailable('teammate-row-invalid', {
286
+ actorSessionId: actorId,
287
+ rootSessionId: rootId,
288
+ status: typeof row.status === 'string' ? row.status : null,
289
+ });
290
+ }
291
+ } else {
292
+ // The Lead's own domain: read the roster too, so a membership change moves the revision
293
+ // even when only teammates changed (a transferred page must see that change as stale).
294
+ rows = this.readRoster(teamService, membership.root, { actorSessionId: actorId, rootSessionId: rootId });
295
+ }
296
+ fingerprint = rosterFingerprint(rows);
297
+ }
298
+
299
+ // 5. Version bookkeeping.
300
+ const state = this.actorState(actorId);
301
+ const previous = this.records.get(agent);
302
+ let executionEpoch;
303
+ if (previous !== undefined && state.activeAgent === agent) {
304
+ executionEpoch = previous.executionEpoch;
305
+ } else {
306
+ if (state.activeAgent !== null && state.activeAgent !== agent) {
307
+ // A newer instance of the same Session exists: the older object is dead from now on, even
308
+ // if the host later hands it back to us (resume of a resume).
309
+ this.revoked.add(state.activeAgent);
310
+ }
311
+ state.executionEpoch += 1;
312
+ state.activeAgent = agent;
313
+ executionEpoch = state.executionEpoch;
314
+ }
315
+
316
+ const identityStateChanged = state.rootId !== rootId
317
+ || state.role !== role
318
+ || state.fingerprint !== fingerprint
319
+ || state.agentRegistry !== (agentRegistry ?? null)
320
+ || state.teamService !== (teamService ?? null)
321
+ || state.invalidated === true;
322
+ if (state.membershipRevision === 0 || identityStateChanged) state.membershipRevision += 1;
323
+ state.rootId = rootId;
324
+ state.role = role;
325
+ state.fingerprint = fingerprint;
326
+ state.agentRegistry = agentRegistry ?? null;
327
+ state.teamService = teamService ?? null;
328
+ state.invalidated = false;
329
+
330
+ const record = Object.freeze({
331
+ actorId,
332
+ exactAgent: agent,
333
+ rootId,
334
+ role,
335
+ rosterFingerprint: fingerprint,
336
+ membershipRevision: state.membershipRevision,
337
+ executionEpoch,
338
+ source: PRINCIPAL_SOURCE.AGENT,
339
+ hostEpoch: this.hostEpoch,
340
+ status: 'active',
341
+ resolvedAt: now,
342
+ });
343
+ this.records.set(agent, record);
344
+
345
+ return makePrincipal({
346
+ actorSessionId: actorId,
347
+ rootSessionId: rootId,
348
+ role,
349
+ membershipRevision: record.membershipRevision,
350
+ executionEpoch: record.executionEpoch,
351
+ source: PRINCIPAL_SOURCE.AGENT,
352
+ hostEpoch: record.hostEpoch,
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Last resolved record for one agent object (diagnostics only; never an authorisation input).
358
+ * @param {object} agent
359
+ * @returns {Readonly<object>|null}
360
+ */
361
+ recordFor(agent) {
362
+ if (!isPlainObject(agent)) return null;
363
+ return this.records.get(agent) ?? null;
364
+ }
365
+
366
+ /**
367
+ * Content-free summary for readiness/diagnostics: no ids, no roster, just counters and the boot.
368
+ * @returns {{hostEpoch:string, actors:number}}
369
+ */
370
+ summary() {
371
+ return { hostEpoch: this.hostEpoch, actors: this.actors.size };
372
+ }
373
+
374
+ /**
375
+ * Call the native membership probe.
376
+ *
377
+ * @returns {object|undefined} the membership, or undefined when the host could not prove one
378
+ */
379
+ readMembership(teamService, agent) {
380
+ if (!isPlainObject(teamService)) return undefined;
381
+ const { tryMembership, membership } = teamService;
382
+ if (typeof tryMembership === 'function') {
383
+ try {
384
+ return tryMembership.call(teamService, agent);
385
+ } catch {
386
+ // A throwing probe is "could not prove", not "proved false".
387
+ return undefined;
388
+ }
389
+ }
390
+ if (typeof membership === 'function') {
391
+ try {
392
+ return membership.call(teamService, agent);
393
+ } catch {
394
+ return undefined;
395
+ }
396
+ }
397
+ return undefined;
398
+ }
399
+
400
+ /**
401
+ * Refresh the roster from the native service. Missing or throwing service ⇒ refusal, because an
402
+ * unreadable roster cannot establish that a teammate is still one.
403
+ *
404
+ * @returns {Array<object>}
405
+ */
406
+ readRoster(teamService, rootAgent, context) {
407
+ if (!isPlainObject(teamService) || typeof teamService.listMembers !== 'function') {
408
+ throw identityUnavailable('roster-unavailable', context);
409
+ }
410
+ let rows;
411
+ try {
412
+ rows = teamService.listMembers.call(teamService, rootAgent);
413
+ } catch (cause) {
414
+ throw new AdapterError(ERROR_CODES.TAB_IDENTITY_UNAVAILABLE, 'identity unavailable: roster-failed', {
415
+ details: { reason: 'roster-failed', ...context },
416
+ cause,
417
+ });
418
+ }
419
+ if (!Array.isArray(rows)) throw identityUnavailable('roster-malformed', context);
420
+ return rows;
421
+ }
422
+
423
+ /**
424
+ * Explicit standalone proof.
425
+ *
426
+ * The ruling allows a standalone domain only when the *host* says so (there is no native Team
427
+ * service to ask). The proof is a callback, never a parameter a model can fill in: it must return
428
+ * a truthy value, and if it names a root that root must be the actor itself.
429
+ *
430
+ * @returns {string|null} the proved root id, or null when there is no proof
431
+ */
432
+ readStandaloneProof(standaloneProof, agent, context) {
433
+ if (typeof standaloneProof !== 'function') return null;
434
+ let proof;
435
+ try {
436
+ proof = standaloneProof(agent, context);
437
+ } catch {
438
+ return null;
439
+ }
440
+ if (proof === false || proof === null || proof === undefined) return null;
441
+ if (proof === true) return agent.id;
442
+ if (typeof proof === 'string') return proof === agent.id ? proof : null;
443
+ if (isPlainObject(proof)) {
444
+ const root = proof.rootSessionId;
445
+ if (root === undefined) return agent.id;
446
+ return root === agent.id ? root : null;
447
+ }
448
+ return null;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Process-wide resolver used by `resolvePrincipal` when no instance is injected.
454
+ *
455
+ * One instance per boot is the point: `hostEpoch` must not change between two calls of the same
456
+ * process, otherwise every observation would be "stale" the moment it is compared.
457
+ */
458
+ const DEFAULT_RESOLVER = new TabPrincipalResolver();
459
+
460
+ /**
461
+ * Resolve one `exec.agent` into a Principal through the injected seam.
462
+ *
463
+ * Deliberately a plain function with explicit dependencies: the Host composition root passes
464
+ * `{agentRegistry, teamService, now}` (and, only if the host really has no Team service, an
465
+ * explicit `standaloneProof`), and tests pass fakes. Nothing here reaches for a Cordis context.
466
+ *
467
+ * @param {object} agent
468
+ * @param {{agentRegistry?:object, teamService?:object, now?:number|Function,
469
+ * standaloneProof?:Function, identityResolver?:TabPrincipalResolver}} [deps]
470
+ * @returns {Readonly<object>} branded Principal
471
+ * @throws {AdapterError} TAB_IDENTITY_UNAVAILABLE (or INVALID_ARGUMENT for a malformed seam)
472
+ */
473
+ export function resolvePrincipal(agent, deps = {}) {
474
+ const resolver = deps.identityResolver ?? DEFAULT_RESOLVER;
475
+ if (typeof resolver?.resolve !== 'function') {
476
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'identityResolver must expose resolve()');
477
+ }
478
+ return resolver.resolve(agent, deps);
479
+ }
480
+
481
+ /**
482
+ * Create an independent resolver (one per plugin boot).
483
+ * @param {{hostEpoch?:string, now?:number|Function}} [options]
484
+ * @returns {TabPrincipalResolver}
485
+ */
486
+ export function createTabPrincipalResolver(options = {}) {
487
+ return new TabPrincipalResolver(options);
488
+ }
489
+
490
+ /** The process-wide instance, for Host wiring that needs `invalidate()` on agent/disposed. */
491
+ export function defaultTabPrincipalResolver() {
492
+ return DEFAULT_RESOLVER;
493
+ }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "UI Testing",
4
- "description": "Frontend GUI debugging and testing for DeepSeekHarness: Web/Chromium is implemented; Windows/Linux/macOS desktop, Android, TUI, HarmonyOS, games and 3D (eight environments) are planned."
4
+ "description": "Frontend GUI debugging and testing for DeepSeekHarness: Web/Chromium is implemented; Windows/Linux/macOS desktop, Android, TUI, HarmonyOS, mini programs, games and 3D (nine environments) are planned."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "UI 测试",
4
- "description": "面向 DeepSeekHarness 的前端 GUI 调试与测试插件:Web / Chromium 已实现;Windows/Linux/macOS 桌面、Android、TUI、HarmonyOS、游戏与 3D 共八种调试环境规划中。"
4
+ "description": "面向 DeepSeekHarness 的前端 GUI 调试与测试插件:Web / Chromium 已实现;Windows/Linux/macOS 桌面、Android、TUI、HarmonyOS、小程序、游戏与 3D 共九种调试环境规划中。"
5
5
  }
6
6
  }
package/network-p0.js CHANGED
@@ -1775,23 +1775,40 @@ function matchScalar(fieldValue, wanted, normalizer = (value) => String(value).t
1775
1775
  return list.some((item) => normalizer(fieldValue) === normalizer(item));
1776
1776
  }
1777
1777
 
1778
- /** R1-1:`stepId` 是查询期按窗口派生的归属(`stepIds` 是显式链接)。 */
1778
+ /**
1779
+ * R1-1:`stepId` 是查询期按窗口派生的归属(`stepIds` 是显式链接)。
1780
+ *
1781
+ * A step window has two bounds and membership needs both: the wall-clock window
1782
+ * (`fromWallMs`/`toWallMs`) and the sequence window (`fromSeq`/`toSeq`). Wall clock alone is not
1783
+ * enough — an event that was already in the store when the step opened can carry a *later*
1784
+ * `ts.wallMs` (its response/finish phase is stamped after `begin-step` raced past it), and a
1785
+ * wall-only test then counts pre-step traffic as in-step. The seq bounds are exact, so they decide.
1786
+ */
1787
+ export function stepMembership(store, event, step) {
1788
+ if (!step) return false;
1789
+ const fromWall = step.window?.fromWallMs;
1790
+ const toWall = step.window?.toWallMs === null || step.window?.toWallMs === undefined
1791
+ ? Number.POSITIVE_INFINITY
1792
+ : step.window.toWallMs;
1793
+ const wallOk = Number.isFinite(fromWall) ? event.ts.wallMs >= fromWall && event.ts.wallMs <= toWall : true;
1794
+ const fromSeq = Number.isInteger(step.fromSeq) ? step.fromSeq : null;
1795
+ const toSeq = Number.isInteger(step.toSeq) ? step.toSeq : null;
1796
+ const seqOk = (fromSeq === null || event.seq >= fromSeq) && (toSeq === null || event.seq <= toSeq);
1797
+ return wallOk && seqOk;
1798
+ }
1799
+
1779
1800
  export function derivedStepId(store, event) {
1780
1801
  if (event.stepId) return event.stepId;
1781
1802
  if (event.stepIds.length) return event.stepIds[0];
1782
1803
  for (const step of store.steps.values()) {
1783
- const to = step.window.toWallMs === null || step.window.toWallMs === undefined ? Number.POSITIVE_INFINITY : step.window.toWallMs;
1784
- if (event.ts.wallMs >= step.window.fromWallMs && event.ts.wallMs <= to) return step.stepId;
1804
+ if (stepMembership(store, event, step)) return step.stepId;
1785
1805
  }
1786
1806
  return null;
1787
1807
  }
1788
1808
 
1789
1809
  export function inStepWindow(store, event, stepId) {
1790
1810
  if (event.stepId === stepId || event.stepIds.includes(String(stepId))) return true;
1791
- const step = store.steps.get(String(stepId));
1792
- if (!step) return false;
1793
- const to = step.window.toWallMs === null || step.window.toWallMs === undefined ? Number.POSITIVE_INFINITY : step.window.toWallMs;
1794
- return event.ts.wallMs >= step.window.fromWallMs && event.ts.wallMs <= to;
1811
+ return stepMembership(store, event, store.steps.get(String(stepId)));
1795
1812
  }
1796
1813
 
1797
1814
  export function matchesFilter(store, event, filter) {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-agent-team-ui-testing",
3
- "version": "0.1.1",
4
- "description": "Frontend GUI debugging and testing for DeepSeekHarness agents: Web/Chromium is implemented with 36 browser_* tools for structured GUI automation, visual comparison, dynamic frame analysis and network debugging; Windows/Linux/macOS desktop, Android, TUI, HarmonyOS, games and 3D (eight environments) are planned.",
3
+ "version": "0.1.2",
4
+ "description": "Frontend GUI debugging and testing for DeepSeekHarness agents: Web/Chromium is implemented with 37 browser_* tools for structured GUI automation, visual comparison, dynamic frame analysis, annotation read/write and network debugging; Windows/Linux/macOS desktop, Android, TUI, HarmonyOS, mini programs, games and 3D (nine environments) are planned.",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "keywords": ["deepseek-harness", "dsh-plugin", "ui-testing", "gui-testing", "test-automation", "browser-automation", "visual-regression", "network-debugging"],
7
8
  "repository": { "type": "git", "url": "https://github.com/bloodarea/dsh-agent-team-ui-testing.git" },
@@ -12,7 +13,7 @@
12
13
  "./locale/*.json": "./locale/*.json",
13
14
  "./icon": "./icon.png"
14
15
  },
15
- "files": ["index.js", "client.js", "frame-source.js", "frame-push.js", "browser-safety.js", "capture-p0.js", "vision-t3.js", "vision-baseline.js", "network-p0.js", "dynamic-t4.js", "cordis.patch.yml", "locale/*.json", "icon.png", "banner.png", "banner_zh.png", "docs/*.md", "skills/**"],
16
+ "files": ["index.js", "client.js", "select-control.js", "context-menu.js", "frame-source.js", "frame-push.js", "browser-safety.js", "capture-p0.js", "annotation-capture.js", "vision-t3.js", "vision-baseline.js", "network-p0.js", "dynamic-t4.js", "cordis.patch.yml", "locale/*.json", "icon.png", "banner.png", "banner_zh.png", "docs/*.md", "core/", "targets/", "integration/", "skills/**"],
16
17
  "dsh": {
17
18
  "bundle": { "patch": "./cordis.patch.yml" },
18
19
  "client": { "platform": "web", "immediately": true, "inject": [] }