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.
- package/LICENSE +21 -0
- package/README.md +172 -36
- package/annotation-capture.js +111 -0
- package/browser-safety.js +45 -1
- package/client.js +3721 -132
- package/context-menu.js +134 -0
- package/core/clock.js +88 -0
- package/core/config-store.js +566 -0
- package/core/config.js +892 -0
- package/core/contracts.js +240 -0
- package/core/errors.js +156 -0
- package/core/evidence.js +141 -0
- package/core/geometry.js +217 -0
- package/core/identity.js +180 -0
- package/core/index.js +24 -0
- package/core/p0.js +1101 -0
- package/core/readiness.js +175 -0
- package/core/registry.js +239 -0
- package/core/tab-access.js +1350 -0
- package/docs/adapters.md +160 -0
- package/docs/architecture.md +44 -9
- package/docs/capability-tiers.md +20 -4
- package/docs/capture-p0.md +39 -2
- package/docs/diagnose-reason-codes.md +157 -0
- package/docs/element-picker.md +24 -6
- package/docs/getting-started.md +47 -1
- package/docs/images/tutorial-01-overview.png +0 -0
- package/docs/images/tutorial-02-history-dropdown.png +0 -0
- package/docs/images/tutorial-03-pick-element.png +0 -0
- package/docs/images/tutorial-04-annotate.png +0 -0
- package/docs/index.md +11 -7
- package/docs/multi-tab.md +55 -20
- package/docs/network-p0.md +2 -2
- package/docs/panel-context-menu.md +41 -0
- package/docs/t2-structured-gui.md +2 -2
- package/docs/t3-vision.md +6 -1
- package/docs/t4-dynamic-behavior.md +1 -0
- package/docs/tools.md +65 -7
- package/docs/tutorial.md +125 -0
- package/docs/verification.md +26 -2
- package/docs/viewport-control.md +21 -8
- package/index.js +3263 -251
- package/integration/cross-environment.js +1535 -0
- package/integration/diagnose.js +945 -0
- package/integration/tab-principal.js +493 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/network-p0.js +24 -7
- package/package.json +4 -3
- package/select-control.js +298 -0
- package/skills/ui-testing-common/SKILL.md +54 -0
- package/skills/ui-testing-fake/SKILL.md +43 -0
- package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
- package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
- package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
- package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
- package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
- package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
- package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
- package/targets/fake/adapter.js +1163 -0
- package/targets/fake/fixtures.js +177 -0
- package/targets/web/adapter.js +1231 -0
- package/targets/web/provider.js +415 -0
- package/skills/ui-testing/references/scenarios/annotations.md +0 -87
- /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
- /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 (
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Frontend GUI debugging and testing for DeepSeekHarness agents: Web/Chromium is implemented with
|
|
3
|
+
"version": "0.1.3",
|
|
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", "docs/images/", "core/", "targets/", "integration/", "skills/**"],
|
|
16
17
|
"dsh": {
|
|
17
18
|
"bundle": { "patch": "./cordis.patch.yml" },
|
|
18
19
|
"client": { "platform": "web", "immediately": true, "inject": [] }
|