@happyvertical/smrt-ui 0.43.4 → 0.43.6
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/AGENTS.md +33 -0
- package/README.md +78 -4
- package/dist/components/display/CurrencyDisplay.contract.d.ts +7 -0
- package/dist/components/display/CurrencyDisplay.contract.d.ts.map +1 -0
- package/dist/components/display/CurrencyDisplay.contract.js +1 -0
- package/dist/components/display/CurrencyDisplay.svelte +148 -17
- package/dist/components/display/CurrencyDisplay.svelte.d.ts +8 -5
- package/dist/components/display/CurrencyDisplay.svelte.d.ts.map +1 -1
- package/dist/components/display/__tests__/CurrencyDisplay.test.js +193 -3
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte +20 -0
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts +19 -0
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts.map +1 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte +7 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts +19 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts.map +1 -0
- package/dist/components/display/currency-metadata.d.ts +2 -0
- package/dist/components/display/currency-metadata.d.ts.map +1 -0
- package/dist/components/display/currency-metadata.js +14 -0
- package/dist/components/forms/Checkbox.svelte +15 -6
- package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/Combobox.svelte +45 -10
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/FilePicker.svelte +5 -2
- package/dist/components/forms/Form.svelte +57 -0
- package/dist/components/forms/Form.svelte.d.ts +2 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/Input.svelte +59 -13
- package/dist/components/forms/Input.svelte.d.ts.map +1 -1
- package/dist/components/forms/Listbox.svelte +33 -6
- package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/MultiSelect.svelte +41 -19
- package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
- package/dist/components/forms/RadioGroup.svelte +14 -3
- package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
- package/dist/components/forms/RangeSlider.svelte +61 -6
- package/dist/components/forms/RangeSlider.svelte.d.ts.map +1 -1
- package/dist/components/forms/SegmentedControl.svelte +33 -6
- package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
- package/dist/components/forms/Select.svelte +32 -9
- package/dist/components/forms/Select.svelte.d.ts.map +1 -1
- package/dist/components/forms/Slider.svelte +26 -6
- package/dist/components/forms/Slider.svelte.d.ts.map +1 -1
- package/dist/components/forms/StagedControlReview.svelte +637 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts +13 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -0
- package/dist/components/forms/Switch.svelte +16 -7
- package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
- package/dist/components/forms/TagsInput.svelte +36 -8
- package/dist/components/forms/TagsInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/Textarea.svelte +20 -8
- package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
- package/dist/components/forms/ToggleButton.svelte +20 -3
- package/dist/components/forms/ToggleButton.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/Form.test.js +106 -0
- package/dist/components/forms/__tests__/StagedControlReview.test.js +668 -0
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte +22 -4
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts +6 -2
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/composite-controls.test.js +96 -1
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte +41 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/control-interaction.test.js +3323 -15
- package/dist/components/forms/__tests__/core-controls.fixture.svelte +16 -5
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts +1 -0
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/core-controls.test.js +223 -2
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte +21 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte +23 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte +56 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/control-interaction-context.d.ts +9 -1
- package/dist/components/forms/control-interaction-context.d.ts.map +1 -1
- package/dist/components/forms/control-interaction-context.js +16 -0
- package/dist/components/forms/control-interaction.d.ts +97 -7
- package/dist/components/forms/control-interaction.d.ts.map +1 -1
- package/dist/components/forms/control-interaction.js +1019 -92
- package/dist/components/forms/control-value-validation.d.ts +19 -0
- package/dist/components/forms/control-value-validation.d.ts.map +1 -0
- package/dist/components/forms/control-value-validation.js +152 -0
- package/dist/components/forms/index.d.ts +3 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +2 -1
- package/dist/components/forms/staged-control-review.d.ts +21 -0
- package/dist/components/forms/staged-control-review.d.ts.map +1 -0
- package/dist/components/forms/staged-control-review.js +1 -0
- package/dist/components/forms/use-control-registration.svelte.d.ts.map +1 -1
- package/dist/components/forms/use-control-registration.svelte.js +23 -4
- package/dist/i18n/strings.d.ts +3 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +3 -0
- package/dist/i18n/strings.ui.d.ts +18 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +19 -0
- package/dist/svelte/playground/InteractiveControlsPreview.svelte +3 -4
- package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { createControlInteractionRegistry, } from '../control-interaction.js';
|
|
2
|
+
import { createControlInteractionRegistry, executeLocalControlBatch, executeLocalControlCommand, } from '../control-interaction.js';
|
|
3
3
|
const identity = {
|
|
4
4
|
formId: 'profile',
|
|
5
5
|
controlId: 'display-name',
|
|
6
6
|
};
|
|
7
|
+
function localGestureEvent() {
|
|
8
|
+
const event = new Event('click');
|
|
9
|
+
const implementationSymbol = Object.getOwnPropertySymbols(event).find((symbol) => symbol.description === 'impl');
|
|
10
|
+
if (!implementationSymbol)
|
|
11
|
+
throw new Error('missing JSDOM event internals');
|
|
12
|
+
const implementation = event[implementationSymbol];
|
|
13
|
+
implementation.eventPhase = Event.AT_TARGET;
|
|
14
|
+
return event;
|
|
15
|
+
}
|
|
16
|
+
function dispatchLocalGesture(execute) {
|
|
17
|
+
const target = new EventTarget();
|
|
18
|
+
let pending;
|
|
19
|
+
target.addEventListener('click', (event) => {
|
|
20
|
+
pending = execute(event);
|
|
21
|
+
}, { once: true });
|
|
22
|
+
target.dispatchEvent(new Event('click'));
|
|
23
|
+
if (!pending)
|
|
24
|
+
throw new Error('local gesture handler did not run');
|
|
25
|
+
return pending;
|
|
26
|
+
}
|
|
7
27
|
describe('control interaction registry', () => {
|
|
8
28
|
it('discovers serializable metadata and current state', () => {
|
|
9
29
|
let value = 'Ada';
|
|
@@ -29,29 +49,747 @@ describe('control interaction registry', () => {
|
|
|
29
49
|
}),
|
|
30
50
|
]);
|
|
31
51
|
});
|
|
32
|
-
it('
|
|
52
|
+
it('does not expose mutable aliases through public snapshots', () => {
|
|
53
|
+
const value = { profile: { name: 'Ada' } };
|
|
54
|
+
const registry = createControlInteractionRegistry();
|
|
55
|
+
registry.register({
|
|
56
|
+
identity,
|
|
57
|
+
metadata: { kind: 'custom' },
|
|
58
|
+
getValue: () => value,
|
|
59
|
+
setValue: () => undefined,
|
|
60
|
+
});
|
|
61
|
+
const snapshot = registry.get(identity);
|
|
62
|
+
if (!snapshot)
|
|
63
|
+
throw new Error('missing snapshot');
|
|
64
|
+
snapshot.state.value.profile.name = 'Grace';
|
|
65
|
+
expect(value.profile.name).toBe('Ada');
|
|
66
|
+
});
|
|
67
|
+
it('isolates retained validator aliases from staged and setter candidates', async () => {
|
|
68
|
+
let value = { role: 'viewer' };
|
|
69
|
+
const retainedCandidates = [];
|
|
70
|
+
const setterCandidates = [];
|
|
71
|
+
const policyStagedValues = [];
|
|
72
|
+
const registry = createControlInteractionRegistry({
|
|
73
|
+
isLocalGesture: () => true,
|
|
74
|
+
policy: (command, _context, snapshot) => {
|
|
75
|
+
if (command.action === 'apply') {
|
|
76
|
+
policyStagedValues.push(snapshot.state.staged?.value);
|
|
77
|
+
}
|
|
78
|
+
return { allowed: true };
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
registry.register({
|
|
82
|
+
identity,
|
|
83
|
+
metadata: { kind: 'custom' },
|
|
84
|
+
getValue: () => value,
|
|
85
|
+
setValue: (candidate) => {
|
|
86
|
+
const next = candidate;
|
|
87
|
+
setterCandidates.push(next);
|
|
88
|
+
value = next;
|
|
89
|
+
},
|
|
90
|
+
validateValue: (candidate) => {
|
|
91
|
+
const retained = candidate;
|
|
92
|
+
retainedCandidates.push(retained);
|
|
93
|
+
retained.role = 'admin';
|
|
94
|
+
return true;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(await registry.execute({ action: 'stage', identity, value: { role: 'viewer' } }, { source: 'agent' })).toMatchObject({ ok: true });
|
|
98
|
+
expect(registry.get(identity)?.state.staged?.value).toEqual({
|
|
99
|
+
role: 'viewer',
|
|
100
|
+
});
|
|
101
|
+
expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, event))).toMatchObject({ ok: true });
|
|
102
|
+
expect(policyStagedValues).toEqual([{ role: 'viewer' }]);
|
|
103
|
+
expect(value).toEqual({ role: 'viewer' });
|
|
104
|
+
expect(setterCandidates[0]).not.toBe(retainedCandidates.at(-1));
|
|
105
|
+
const retainedAfterApply = retainedCandidates.at(-1);
|
|
106
|
+
if (!retainedAfterApply)
|
|
107
|
+
throw new Error('validator was not invoked');
|
|
108
|
+
retainedAfterApply.role = 'owner';
|
|
109
|
+
expect(value).toEqual({ role: 'viewer' });
|
|
110
|
+
});
|
|
111
|
+
it('fails closed when a public value cannot be cloned', () => {
|
|
112
|
+
const registry = createControlInteractionRegistry();
|
|
113
|
+
registry.register({
|
|
114
|
+
identity,
|
|
115
|
+
metadata: { kind: 'custom' },
|
|
116
|
+
getValue: () => ({ callback: () => undefined }),
|
|
117
|
+
setValue: () => undefined,
|
|
118
|
+
});
|
|
119
|
+
expect(registry.get(identity)?.state).toMatchObject({
|
|
120
|
+
value: undefined,
|
|
121
|
+
valueRedacted: true,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
it('stages with provenance without mutation and only lets a human apply', async () => {
|
|
33
125
|
let value = 'Ada';
|
|
126
|
+
const events = [];
|
|
34
127
|
const setValue = vi.fn((next) => {
|
|
35
128
|
value = String(next);
|
|
36
129
|
});
|
|
37
|
-
const registry = createControlInteractionRegistry(
|
|
130
|
+
const registry = createControlInteractionRegistry({
|
|
131
|
+
isLocalGesture: () => true,
|
|
132
|
+
});
|
|
133
|
+
registry.subscribe((event) => events.push(event));
|
|
38
134
|
registry.register({
|
|
39
135
|
identity,
|
|
40
136
|
metadata: { kind: 'text', label: 'Display name' },
|
|
41
137
|
getValue: () => value,
|
|
42
138
|
setValue,
|
|
43
139
|
});
|
|
44
|
-
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
140
|
+
const staged = await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent', actorId: 'agent-7', sessionId: 'session-9' });
|
|
141
|
+
expect(staged.snapshot?.state.staged).toMatchObject({
|
|
142
|
+
value: 'Grace',
|
|
143
|
+
revision: 1,
|
|
144
|
+
provenance: {
|
|
145
|
+
source: 'agent',
|
|
146
|
+
actorId: 'agent-7',
|
|
147
|
+
sessionId: 'session-9',
|
|
148
|
+
},
|
|
149
|
+
stale: false,
|
|
150
|
+
});
|
|
45
151
|
expect(value).toBe('Ada');
|
|
46
152
|
expect(registry.get(identity)?.state.stagedValue).toBe('Grace');
|
|
47
153
|
const denied = await registry.execute({ action: 'apply', identity }, { source: 'agent' });
|
|
48
|
-
expect(denied).toMatchObject({
|
|
49
|
-
|
|
154
|
+
expect(denied).toMatchObject({
|
|
155
|
+
ok: false,
|
|
156
|
+
reason: 'human_confirmation_required',
|
|
157
|
+
});
|
|
158
|
+
const agentCannotSelfConfirm = await registry.execute({ action: 'apply', identity, revision: 1 }, { source: 'agent', confirmed: true });
|
|
159
|
+
expect(agentCannotSelfConfirm).toMatchObject({
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: 'human_confirmation_required',
|
|
162
|
+
});
|
|
163
|
+
const spoofedHuman = await registry.execute({ action: 'apply', identity, revision: 1 }, { source: 'user', confirmed: true });
|
|
164
|
+
expect(spoofedHuman).toMatchObject({
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: 'local_gesture_required',
|
|
167
|
+
});
|
|
168
|
+
const gesture = localGestureEvent();
|
|
169
|
+
const applied = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, gesture);
|
|
50
170
|
expect(applied.ok).toBe(true);
|
|
51
171
|
expect(value).toBe('Grace');
|
|
52
|
-
|
|
172
|
+
expect(events.at(-1)?.context?.localGesture).toBe(true);
|
|
173
|
+
await registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
174
|
+
const replayed = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 2 }, gesture);
|
|
175
|
+
expect(replayed).toMatchObject({
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: 'local_gesture_required',
|
|
178
|
+
});
|
|
179
|
+
expect(value).toBe('Grace');
|
|
180
|
+
await executeLocalControlCommand(registry, { action: 'discard', identity, revision: 2 }, localGestureEvent());
|
|
181
|
+
const spoofedClear = await registry.execute({ action: 'clear', identity }, { source: 'user', confirmed: true });
|
|
182
|
+
expect(spoofedClear).toMatchObject({
|
|
183
|
+
ok: false,
|
|
184
|
+
reason: 'local_gesture_required',
|
|
185
|
+
});
|
|
186
|
+
const spoofedUndo = await registry.execute({ action: 'undo', identity }, { source: 'user', confirmed: true });
|
|
187
|
+
expect(spoofedUndo).toMatchObject({
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: 'local_gesture_required',
|
|
190
|
+
});
|
|
191
|
+
await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent());
|
|
192
|
+
expect(value).toBe('Ada');
|
|
193
|
+
});
|
|
194
|
+
it('rejects synthetic events unless the host explicitly recognizes them', async () => {
|
|
195
|
+
let value = 'Ada';
|
|
196
|
+
const registry = createControlInteractionRegistry();
|
|
197
|
+
registry.register({
|
|
198
|
+
identity,
|
|
199
|
+
metadata: { kind: 'text' },
|
|
200
|
+
getValue: () => value,
|
|
201
|
+
setValue: (next) => {
|
|
202
|
+
value = String(next);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
206
|
+
const result = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
207
|
+
expect(result).toMatchObject({
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: 'local_gesture_required',
|
|
210
|
+
});
|
|
211
|
+
expect(value).toBe('Ada');
|
|
212
|
+
});
|
|
213
|
+
it('detects stale revisions and competing direct edits without losing the proposal', async () => {
|
|
214
|
+
let value = 'Ada';
|
|
215
|
+
const registry = createControlInteractionRegistry({
|
|
216
|
+
isLocalGesture: () => true,
|
|
217
|
+
});
|
|
218
|
+
registry.register({
|
|
219
|
+
identity,
|
|
220
|
+
metadata: { kind: 'text' },
|
|
221
|
+
getValue: () => value,
|
|
222
|
+
setValue: (next) => {
|
|
223
|
+
value = String(next);
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
227
|
+
const wrongRevision = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 9 }, localGestureEvent());
|
|
228
|
+
expect(wrongRevision).toMatchObject({
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: 'stale_revision',
|
|
231
|
+
});
|
|
232
|
+
value = 'Katherine';
|
|
233
|
+
expect(registry.get(identity)?.state.staged?.stale).toBe(true);
|
|
234
|
+
const stale = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
235
|
+
expect(stale).toMatchObject({ ok: false, reason: 'staged_value_stale' });
|
|
236
|
+
expect(value).toBe('Katherine');
|
|
237
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
238
|
+
const discarded = await executeLocalControlCommand(registry, { action: 'discard', identity, revision: 1 }, localGestureEvent());
|
|
239
|
+
expect(discarded.ok).toBe(true);
|
|
240
|
+
expect(registry.get(identity)?.state.staged).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
it('allows a trusted discard after the control becomes disabled', async () => {
|
|
243
|
+
let disabled = false;
|
|
244
|
+
const registry = createControlInteractionRegistry({
|
|
245
|
+
isLocalGesture: () => true,
|
|
246
|
+
});
|
|
247
|
+
registry.register({
|
|
248
|
+
identity,
|
|
249
|
+
metadata: { kind: 'text' },
|
|
250
|
+
getValue: () => 'Ada',
|
|
251
|
+
setValue: () => undefined,
|
|
252
|
+
getState: () => ({ disabled }),
|
|
253
|
+
});
|
|
254
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
255
|
+
disabled = true;
|
|
256
|
+
expect(await executeLocalControlCommand(registry, { action: 'discard', identity, revision: 1 }, localGestureEvent())).toMatchObject({ ok: true });
|
|
257
|
+
expect(registry.get(identity)?.state.staged).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
it('rechecks disabled state after an asynchronous policy decision', async () => {
|
|
260
|
+
let disabled = false;
|
|
261
|
+
let releasePolicy;
|
|
262
|
+
let policyStarted;
|
|
263
|
+
const policyGate = new Promise((resolve) => {
|
|
264
|
+
releasePolicy = resolve;
|
|
265
|
+
});
|
|
266
|
+
const started = new Promise((resolve) => {
|
|
267
|
+
policyStarted = resolve;
|
|
268
|
+
});
|
|
269
|
+
let value = 'Ada';
|
|
270
|
+
const registry = createControlInteractionRegistry({
|
|
271
|
+
isLocalGesture: () => true,
|
|
272
|
+
policy: async (command) => {
|
|
273
|
+
if (command.action === 'apply') {
|
|
274
|
+
policyStarted?.();
|
|
275
|
+
await policyGate;
|
|
276
|
+
}
|
|
277
|
+
return { allowed: true };
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
registry.register({
|
|
281
|
+
identity,
|
|
282
|
+
metadata: { kind: 'text' },
|
|
283
|
+
getValue: () => value,
|
|
284
|
+
setValue: (next) => {
|
|
285
|
+
value = String(next);
|
|
286
|
+
},
|
|
287
|
+
getState: () => ({ disabled }),
|
|
288
|
+
});
|
|
289
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
290
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
291
|
+
await started;
|
|
292
|
+
disabled = true;
|
|
293
|
+
releasePolicy?.();
|
|
294
|
+
expect(await applying).toMatchObject({
|
|
295
|
+
ok: false,
|
|
296
|
+
reason: 'control_not_editable',
|
|
297
|
+
});
|
|
298
|
+
expect(value).toBe('Ada');
|
|
299
|
+
});
|
|
300
|
+
it('rechecks prepared stage values after an asynchronous policy decision', async () => {
|
|
301
|
+
let value = 'Existing';
|
|
302
|
+
let releasePolicy;
|
|
303
|
+
let policyStarted;
|
|
304
|
+
const policyGate = new Promise((resolve) => {
|
|
305
|
+
releasePolicy = resolve;
|
|
306
|
+
});
|
|
307
|
+
const started = new Promise((resolve) => {
|
|
308
|
+
policyStarted = resolve;
|
|
309
|
+
});
|
|
310
|
+
const policyValues = [];
|
|
311
|
+
const registry = createControlInteractionRegistry({
|
|
312
|
+
policy: async (command) => {
|
|
313
|
+
if (command.action === 'stage') {
|
|
314
|
+
policyValues.push(command.value);
|
|
315
|
+
if (policyValues.length === 1) {
|
|
316
|
+
policyStarted?.();
|
|
317
|
+
await policyGate;
|
|
318
|
+
}
|
|
319
|
+
if (String(command.value).includes('Forbidden')) {
|
|
320
|
+
return { allowed: false, reason: 'forbidden_content' };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { allowed: true };
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
registry.register({
|
|
327
|
+
identity,
|
|
328
|
+
metadata: { kind: 'textarea' },
|
|
329
|
+
getValue: () => value,
|
|
330
|
+
prepareValue: (proposal) => `${value}\n${String(proposal)}`,
|
|
331
|
+
setValue: (next) => {
|
|
332
|
+
value = String(next);
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
const staging = registry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
336
|
+
await started;
|
|
337
|
+
value = 'Existing human';
|
|
338
|
+
releasePolicy?.();
|
|
339
|
+
expect(await staging).toMatchObject({ ok: true });
|
|
340
|
+
expect(policyValues).toEqual([
|
|
341
|
+
'Existing\nProposed',
|
|
342
|
+
'Existing human\nProposed',
|
|
343
|
+
]);
|
|
344
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Existing human\nProposed');
|
|
345
|
+
expect(await registry.execute({ action: 'stage', identity, value: 'Forbidden' }, { source: 'agent' })).toMatchObject({ ok: false, reason: 'forbidden_content' });
|
|
346
|
+
expect(policyValues.at(-1)).toBe('Existing human\nForbidden');
|
|
347
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Existing human\nProposed');
|
|
348
|
+
});
|
|
349
|
+
it('distinguishes unchanged and canonical review values from raw edited proposals', async () => {
|
|
350
|
+
let value = 'Existing';
|
|
351
|
+
const prepareValue = vi.fn((proposal) => `${value}\n${String(proposal)}`);
|
|
352
|
+
const prepareReviewedValue = vi.fn((reviewed) => String(reviewed));
|
|
353
|
+
const registry = createControlInteractionRegistry({
|
|
354
|
+
isLocalGesture: () => true,
|
|
355
|
+
});
|
|
356
|
+
registry.register({
|
|
357
|
+
identity,
|
|
358
|
+
metadata: { kind: 'textarea' },
|
|
359
|
+
getValue: () => value,
|
|
360
|
+
prepareValue,
|
|
361
|
+
prepareReviewedValue,
|
|
362
|
+
setValue: (next) => {
|
|
363
|
+
value = String(next);
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
await registry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
367
|
+
const reviewedValue = registry.get(identity)?.state.staged?.value;
|
|
368
|
+
expect(reviewedValue).toBe('Existing\nProposed');
|
|
369
|
+
expect(prepareValue).toHaveBeenCalledTimes(1);
|
|
370
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1, value: reviewedValue }, localGestureEvent())).toMatchObject({ ok: true });
|
|
371
|
+
expect(value).toBe('Existing\nProposed');
|
|
372
|
+
expect(prepareValue).toHaveBeenCalledTimes(1);
|
|
373
|
+
value = 'Existing';
|
|
374
|
+
await registry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
375
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 2, value: 'Edited' }, localGestureEvent())).toMatchObject({ ok: true });
|
|
376
|
+
expect(value).toBe('Existing\nEdited');
|
|
377
|
+
expect(prepareValue).toHaveBeenCalledTimes(3);
|
|
378
|
+
value = 'Existing';
|
|
379
|
+
await registry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
380
|
+
expect(prepareValue).toHaveBeenCalledTimes(4);
|
|
381
|
+
expect(await executeLocalControlCommand(registry, {
|
|
382
|
+
action: 'apply',
|
|
383
|
+
identity,
|
|
384
|
+
revision: 3,
|
|
385
|
+
value: 'Existing\nReviewed edit',
|
|
386
|
+
reviewedValueIsCanonical: true,
|
|
387
|
+
}, localGestureEvent())).toMatchObject({ ok: true });
|
|
388
|
+
expect(value).toBe('Existing\nReviewed edit');
|
|
389
|
+
expect(prepareValue).toHaveBeenCalledTimes(4);
|
|
390
|
+
expect(prepareReviewedValue).toHaveBeenCalledOnce();
|
|
391
|
+
});
|
|
392
|
+
it('routes marked custom review edits through ordinary preparation by default', async () => {
|
|
393
|
+
let value = 'Original';
|
|
394
|
+
const prepareValue = vi.fn((proposal) => {
|
|
395
|
+
const next = String(proposal).trim().toUpperCase();
|
|
396
|
+
if (next === 'FORBIDDEN')
|
|
397
|
+
throw new Error('forbidden_review_value');
|
|
398
|
+
return next;
|
|
399
|
+
});
|
|
400
|
+
const registry = createControlInteractionRegistry({
|
|
401
|
+
isLocalGesture: () => true,
|
|
402
|
+
});
|
|
403
|
+
registry.register({
|
|
404
|
+
identity,
|
|
405
|
+
metadata: { kind: 'custom' },
|
|
406
|
+
getValue: () => value,
|
|
407
|
+
prepareValue,
|
|
408
|
+
setValue: (next) => {
|
|
409
|
+
value = String(next);
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
await registry.execute({ action: 'stage', identity, value: 'proposal' }, { source: 'agent' });
|
|
413
|
+
expect(await executeLocalControlCommand(registry, {
|
|
414
|
+
action: 'apply',
|
|
415
|
+
identity,
|
|
416
|
+
revision: 1,
|
|
417
|
+
value: ' reviewed edit ',
|
|
418
|
+
reviewedValueIsCanonical: true,
|
|
419
|
+
}, localGestureEvent())).toMatchObject({ ok: true });
|
|
420
|
+
expect(value).toBe('REVIEWED EDIT');
|
|
421
|
+
value = 'Original';
|
|
422
|
+
await registry.execute({ action: 'stage', identity, value: 'proposal' }, { source: 'agent' });
|
|
423
|
+
expect(await executeLocalControlCommand(registry, {
|
|
424
|
+
action: 'apply',
|
|
425
|
+
identity,
|
|
426
|
+
revision: 2,
|
|
427
|
+
value: ' forbidden ',
|
|
428
|
+
reviewedValueIsCanonical: true,
|
|
429
|
+
}, localGestureEvent())).toMatchObject({ ok: false, reason: 'forbidden_review_value' });
|
|
430
|
+
expect(value).toBe('Original');
|
|
431
|
+
expect(registry.get(identity)?.state.staged).toBeDefined();
|
|
432
|
+
expect(prepareValue).toHaveBeenCalledTimes(4);
|
|
433
|
+
});
|
|
434
|
+
it('does not let a forged canonical-review marker supply local authority', async () => {
|
|
435
|
+
let value = 'Existing';
|
|
436
|
+
const registry = createControlInteractionRegistry({
|
|
437
|
+
isLocalGesture: () => true,
|
|
438
|
+
});
|
|
439
|
+
registry.register({
|
|
440
|
+
identity,
|
|
441
|
+
metadata: { kind: 'textarea' },
|
|
442
|
+
getValue: () => value,
|
|
443
|
+
prepareValue: (proposal) => `${value}\n${String(proposal)}`,
|
|
444
|
+
setValue: (next) => {
|
|
445
|
+
value = String(next);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
await registry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
449
|
+
const forged = {
|
|
450
|
+
action: 'apply',
|
|
451
|
+
identity,
|
|
452
|
+
revision: 1,
|
|
453
|
+
value: 'Forged final value',
|
|
454
|
+
reviewedValueIsCanonical: true,
|
|
455
|
+
};
|
|
456
|
+
expect(await registry.execute(forged, {
|
|
457
|
+
source: 'agent',
|
|
458
|
+
confirmed: true,
|
|
459
|
+
localGesture: true,
|
|
460
|
+
})).toMatchObject({ ok: false, reason: 'human_confirmation_required' });
|
|
461
|
+
expect(await registry.execute(forged, {
|
|
462
|
+
source: 'user',
|
|
463
|
+
confirmed: true,
|
|
464
|
+
localGesture: true,
|
|
465
|
+
})).toMatchObject({ ok: false, reason: 'local_gesture_required' });
|
|
466
|
+
expect(await executeLocalControlCommand(registry, forged, new Event('click'))).toMatchObject({ ok: false, reason: 'local_gesture_required' });
|
|
467
|
+
expect(value).toBe('Existing');
|
|
468
|
+
let policyValue = 'Existing';
|
|
469
|
+
const policyRegistry = createControlInteractionRegistry({
|
|
470
|
+
isLocalGesture: () => true,
|
|
471
|
+
policy: (command) => command.action === 'apply'
|
|
472
|
+
? { allowed: false, reason: 'review_denied' }
|
|
473
|
+
: { allowed: true },
|
|
474
|
+
});
|
|
475
|
+
policyRegistry.register({
|
|
476
|
+
identity,
|
|
477
|
+
metadata: { kind: 'textarea' },
|
|
478
|
+
getValue: () => policyValue,
|
|
479
|
+
prepareValue: (proposal) => `${policyValue}\n${String(proposal)}`,
|
|
480
|
+
setValue: (next) => {
|
|
481
|
+
policyValue = String(next);
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
await policyRegistry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
485
|
+
expect(await executeLocalControlCommand(policyRegistry, {
|
|
486
|
+
...forged,
|
|
487
|
+
value: 'Existing\nReviewed edit',
|
|
488
|
+
}, localGestureEvent())).toMatchObject({ ok: false, reason: 'review_denied' });
|
|
489
|
+
expect(policyValue).toBe('Existing');
|
|
490
|
+
});
|
|
491
|
+
it('keeps secret, redaction, and staleness protections for matching prepared proposals', async () => {
|
|
492
|
+
let sensitiveValue = 'private';
|
|
493
|
+
const sensitiveEvents = [];
|
|
494
|
+
const sensitivePolicyCommands = [];
|
|
495
|
+
const sensitiveRegistry = createControlInteractionRegistry({
|
|
496
|
+
isLocalGesture: () => true,
|
|
497
|
+
policy: (command) => {
|
|
498
|
+
sensitivePolicyCommands.push(command);
|
|
499
|
+
return { allowed: true };
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
sensitiveRegistry.subscribe((event) => sensitiveEvents.push(event));
|
|
503
|
+
sensitiveRegistry.register({
|
|
504
|
+
identity,
|
|
505
|
+
metadata: { kind: 'textarea', sensitivity: 'sensitive' },
|
|
506
|
+
getValue: () => sensitiveValue,
|
|
507
|
+
prepareValue: (proposal) => `${sensitiveValue}\n${String(proposal)}`,
|
|
508
|
+
setValue: (next) => {
|
|
509
|
+
sensitiveValue = String(next);
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
await sensitiveRegistry.execute({ action: 'stage', identity, value: 'replacement' }, { source: 'agent' });
|
|
513
|
+
expect(await executeLocalControlCommand(sensitiveRegistry, {
|
|
514
|
+
action: 'apply',
|
|
515
|
+
identity,
|
|
516
|
+
revision: 1,
|
|
517
|
+
value: 'private\nreplacement',
|
|
518
|
+
}, localGestureEvent())).toMatchObject({ ok: true });
|
|
519
|
+
expect(sensitiveValue).toBe('private\nreplacement');
|
|
520
|
+
expect(JSON.stringify(sensitiveRegistry.get(identity))).not.toContain('replacement');
|
|
521
|
+
expect(JSON.stringify(sensitiveEvents)).not.toContain('replacement');
|
|
522
|
+
expect(JSON.stringify(sensitivePolicyCommands)).not.toContain('replacement');
|
|
523
|
+
let staleValue = 'Existing';
|
|
524
|
+
const staleRegistry = createControlInteractionRegistry({
|
|
525
|
+
isLocalGesture: () => true,
|
|
526
|
+
});
|
|
527
|
+
staleRegistry.register({
|
|
528
|
+
identity,
|
|
529
|
+
metadata: { kind: 'textarea' },
|
|
530
|
+
getValue: () => staleValue,
|
|
531
|
+
prepareValue: (proposal) => `${staleValue}\n${String(proposal)}`,
|
|
532
|
+
setValue: (next) => {
|
|
533
|
+
staleValue = String(next);
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
await staleRegistry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
537
|
+
staleValue = 'Human edit';
|
|
538
|
+
expect(await executeLocalControlCommand(staleRegistry, {
|
|
539
|
+
action: 'apply',
|
|
540
|
+
identity,
|
|
541
|
+
revision: 1,
|
|
542
|
+
value: 'Existing\nProposed',
|
|
543
|
+
reviewedValueIsCanonical: true,
|
|
544
|
+
}, localGestureEvent())).toMatchObject({ ok: false, reason: 'staged_value_stale' });
|
|
545
|
+
expect(staleValue).toBe('Human edit');
|
|
546
|
+
expect(await executeLocalControlCommand(staleRegistry, {
|
|
547
|
+
action: 'apply',
|
|
548
|
+
identity,
|
|
549
|
+
revision: 9,
|
|
550
|
+
value: 'Human edit',
|
|
551
|
+
reviewedValueIsCanonical: true,
|
|
552
|
+
}, localGestureEvent())).toMatchObject({ ok: false, reason: 'stale_revision' });
|
|
553
|
+
const secretRegistry = createControlInteractionRegistry({
|
|
554
|
+
isLocalGesture: () => true,
|
|
555
|
+
});
|
|
556
|
+
secretRegistry.register({
|
|
557
|
+
identity,
|
|
558
|
+
metadata: { kind: 'password', sensitivity: 'secret' },
|
|
559
|
+
getValue: () => 'token',
|
|
560
|
+
prepareValue: (proposal) => String(proposal),
|
|
561
|
+
setValue: () => undefined,
|
|
562
|
+
});
|
|
563
|
+
expect(await secretRegistry.execute({ action: 'stage', identity, value: 'replacement' }, { source: 'agent' })).toMatchObject({ ok: false, reason: 'sensitive_control' });
|
|
564
|
+
expect(await executeLocalControlCommand(secretRegistry, {
|
|
565
|
+
action: 'apply',
|
|
566
|
+
identity,
|
|
567
|
+
revision: 1,
|
|
568
|
+
value: 'replacement',
|
|
569
|
+
reviewedValueIsCanonical: true,
|
|
570
|
+
}, localGestureEvent())).toMatchObject({ ok: false, reason: 'sensitive_control' });
|
|
571
|
+
});
|
|
572
|
+
it('isolates a prepared structured value throughout asynchronous policy', async () => {
|
|
573
|
+
let releasePolicy;
|
|
574
|
+
let policyStarted;
|
|
575
|
+
const policyGate = new Promise((resolve) => {
|
|
576
|
+
releasePolicy = resolve;
|
|
577
|
+
});
|
|
578
|
+
const started = new Promise((resolve) => {
|
|
579
|
+
policyStarted = resolve;
|
|
580
|
+
});
|
|
581
|
+
const proposal = { label: 'Allowed' };
|
|
582
|
+
const registry = createControlInteractionRegistry({
|
|
583
|
+
policy: async (command) => {
|
|
584
|
+
if (command.action === 'stage') {
|
|
585
|
+
expect(command.value).toEqual({ label: 'Allowed' });
|
|
586
|
+
policyStarted?.();
|
|
587
|
+
await policyGate;
|
|
588
|
+
expect(command.value).toEqual({ label: 'Allowed' });
|
|
589
|
+
}
|
|
590
|
+
return { allowed: true };
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
registry.register({
|
|
594
|
+
identity,
|
|
595
|
+
metadata: { kind: 'custom' },
|
|
596
|
+
getValue: () => null,
|
|
597
|
+
prepareValue: (value) => value,
|
|
598
|
+
setValue: () => undefined,
|
|
599
|
+
});
|
|
600
|
+
const staging = registry.execute({ action: 'stage', identity, value: proposal }, { source: 'agent' });
|
|
601
|
+
await started;
|
|
602
|
+
proposal.label = 'Forbidden';
|
|
603
|
+
releasePolicy?.();
|
|
604
|
+
expect(await staging).toMatchObject({ ok: true });
|
|
605
|
+
expect(registry.get(identity)?.state.staged?.value).toEqual({
|
|
606
|
+
label: 'Allowed',
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
it('rechecks disabled state after asynchronous proposal validation', async () => {
|
|
610
|
+
let disabled = false;
|
|
611
|
+
let blockValidation = false;
|
|
612
|
+
let releaseValidation;
|
|
613
|
+
let validationStarted;
|
|
614
|
+
const validationGate = new Promise((resolve) => {
|
|
615
|
+
releaseValidation = resolve;
|
|
616
|
+
});
|
|
617
|
+
const started = new Promise((resolve) => {
|
|
618
|
+
validationStarted = resolve;
|
|
619
|
+
});
|
|
620
|
+
let value = 'Ada';
|
|
621
|
+
const registry = createControlInteractionRegistry({
|
|
622
|
+
isLocalGesture: () => true,
|
|
623
|
+
});
|
|
624
|
+
registry.register({
|
|
625
|
+
identity,
|
|
626
|
+
metadata: { kind: 'text' },
|
|
627
|
+
getValue: () => value,
|
|
628
|
+
setValue: (next) => {
|
|
629
|
+
value = String(next);
|
|
630
|
+
},
|
|
631
|
+
validateValue: async () => {
|
|
632
|
+
if (blockValidation) {
|
|
633
|
+
validationStarted?.();
|
|
634
|
+
await validationGate;
|
|
635
|
+
}
|
|
636
|
+
return true;
|
|
637
|
+
},
|
|
638
|
+
getState: () => ({ disabled }),
|
|
639
|
+
});
|
|
640
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
641
|
+
blockValidation = true;
|
|
642
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
643
|
+
await started;
|
|
644
|
+
disabled = true;
|
|
645
|
+
releaseValidation?.();
|
|
646
|
+
expect(await applying).toMatchObject({
|
|
647
|
+
ok: false,
|
|
648
|
+
reason: 'control_not_editable',
|
|
649
|
+
});
|
|
53
650
|
expect(value).toBe('Ada');
|
|
54
651
|
});
|
|
652
|
+
it.each([
|
|
653
|
+
{
|
|
654
|
+
capability: 'secret',
|
|
655
|
+
transition: (state) => {
|
|
656
|
+
state.sensitivity = 'secret';
|
|
657
|
+
},
|
|
658
|
+
reset: (state) => {
|
|
659
|
+
state.sensitivity = 'public';
|
|
660
|
+
},
|
|
661
|
+
reason: 'sensitive_control',
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
capability: 'non-writable',
|
|
665
|
+
transition: (state) => {
|
|
666
|
+
state.writable = false;
|
|
667
|
+
},
|
|
668
|
+
reset: (state) => {
|
|
669
|
+
state.writable = true;
|
|
670
|
+
},
|
|
671
|
+
reason: 'control_not_writable',
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
capability: 'disabled',
|
|
675
|
+
transition: (state) => {
|
|
676
|
+
state.disabled = true;
|
|
677
|
+
},
|
|
678
|
+
reset: (state) => {
|
|
679
|
+
state.disabled = false;
|
|
680
|
+
},
|
|
681
|
+
reason: 'control_not_editable',
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
capability: 'readonly',
|
|
685
|
+
transition: (state) => {
|
|
686
|
+
state.readonly = true;
|
|
687
|
+
},
|
|
688
|
+
reset: (state) => {
|
|
689
|
+
state.readonly = false;
|
|
690
|
+
},
|
|
691
|
+
reason: 'control_not_editable',
|
|
692
|
+
},
|
|
693
|
+
])('rejects a stage when the control becomes $capability during validation', async ({ transition, reset, reason, }) => {
|
|
694
|
+
const state = {
|
|
695
|
+
sensitivity: 'public',
|
|
696
|
+
writable: true,
|
|
697
|
+
disabled: false,
|
|
698
|
+
readonly: false,
|
|
699
|
+
};
|
|
700
|
+
let blockReplacement = false;
|
|
701
|
+
let releaseValidation;
|
|
702
|
+
let validationStarted;
|
|
703
|
+
const validationGate = new Promise((resolve) => {
|
|
704
|
+
releaseValidation = resolve;
|
|
705
|
+
});
|
|
706
|
+
const started = new Promise((resolve) => {
|
|
707
|
+
validationStarted = resolve;
|
|
708
|
+
});
|
|
709
|
+
const registry = createControlInteractionRegistry();
|
|
710
|
+
registry.register({
|
|
711
|
+
identity,
|
|
712
|
+
metadata: {
|
|
713
|
+
kind: 'text',
|
|
714
|
+
get sensitivity() {
|
|
715
|
+
return state.sensitivity;
|
|
716
|
+
},
|
|
717
|
+
get writable() {
|
|
718
|
+
return state.writable;
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
getValue: () => 'Ada',
|
|
722
|
+
setValue: () => undefined,
|
|
723
|
+
getState: () => ({
|
|
724
|
+
disabled: state.disabled,
|
|
725
|
+
readonly: state.readonly,
|
|
726
|
+
}),
|
|
727
|
+
validateValue: async (candidate) => {
|
|
728
|
+
if (blockReplacement && candidate === 'Katherine') {
|
|
729
|
+
validationStarted?.();
|
|
730
|
+
await validationGate;
|
|
731
|
+
}
|
|
732
|
+
return true;
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
736
|
+
blockReplacement = true;
|
|
737
|
+
const replacing = registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
738
|
+
await started;
|
|
739
|
+
transition(state);
|
|
740
|
+
releaseValidation?.();
|
|
741
|
+
expect(await replacing).toMatchObject({ ok: false, reason });
|
|
742
|
+
reset(state);
|
|
743
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
744
|
+
value: 'Grace',
|
|
745
|
+
revision: 1,
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
it('keeps invalid proposals staged and returns explicit batch results', async () => {
|
|
749
|
+
let first = 'Ada';
|
|
750
|
+
let second = 'Lovelace';
|
|
751
|
+
const secondIdentity = { ...identity, controlId: 'surname' };
|
|
752
|
+
const registry = createControlInteractionRegistry({
|
|
753
|
+
isLocalGesture: () => true,
|
|
754
|
+
});
|
|
755
|
+
registry.register({
|
|
756
|
+
identity,
|
|
757
|
+
metadata: { kind: 'text' },
|
|
758
|
+
getValue: () => first,
|
|
759
|
+
setValue: (next) => {
|
|
760
|
+
first = String(next);
|
|
761
|
+
},
|
|
762
|
+
validateValue: (next) => String(next).length >= 3 || 'Use at least three characters',
|
|
763
|
+
});
|
|
764
|
+
registry.register({
|
|
765
|
+
identity: secondIdentity,
|
|
766
|
+
metadata: { kind: 'text' },
|
|
767
|
+
getValue: () => second,
|
|
768
|
+
setValue: (next) => {
|
|
769
|
+
second = String(next);
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
await registry.execute({ action: 'stage', identity, value: 'x' }, { source: 'agent' });
|
|
773
|
+
await registry.execute({ action: 'stage', identity: secondIdentity, value: 'Hopper' }, { source: 'agent' });
|
|
774
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
775
|
+
valid: false,
|
|
776
|
+
validationMessage: 'Use at least three characters',
|
|
777
|
+
});
|
|
778
|
+
const batch = await executeLocalControlBatch(registry, [
|
|
779
|
+
{ action: 'apply', identity, revision: 1 },
|
|
780
|
+
{ action: 'apply', identity: secondIdentity, revision: 2 },
|
|
781
|
+
], localGestureEvent());
|
|
782
|
+
expect(batch).toMatchObject({
|
|
783
|
+
ok: false,
|
|
784
|
+
results: [
|
|
785
|
+
{ ok: false, reason: 'Use at least three characters' },
|
|
786
|
+
{ ok: true },
|
|
787
|
+
],
|
|
788
|
+
});
|
|
789
|
+
expect(first).toBe('Ada');
|
|
790
|
+
expect(second).toBe('Hopper');
|
|
791
|
+
expect(registry.get(identity)?.state.staged).toBeDefined();
|
|
792
|
+
});
|
|
55
793
|
it('redacts and denies secret controls by default', async () => {
|
|
56
794
|
let value = 'token';
|
|
57
795
|
const registry = createControlInteractionRegistry();
|
|
@@ -73,17 +811,1804 @@ describe('control interaction registry', () => {
|
|
|
73
811
|
expect(result).toMatchObject({ ok: false, reason: 'sensitive_control' });
|
|
74
812
|
expect(value).toBe('token');
|
|
75
813
|
});
|
|
76
|
-
it('
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
814
|
+
it('hides dynamically forbidden proposals while retaining a trusted discard escape hatch', async () => {
|
|
815
|
+
let value = 'Ada';
|
|
816
|
+
const setValue = vi.fn((next) => {
|
|
817
|
+
value = String(next);
|
|
818
|
+
});
|
|
819
|
+
const metadata = { kind: 'text', writable: true };
|
|
820
|
+
const registry = createControlInteractionRegistry({
|
|
821
|
+
isLocalGesture: () => true,
|
|
822
|
+
});
|
|
823
|
+
registry.register({
|
|
824
|
+
identity,
|
|
825
|
+
metadata,
|
|
826
|
+
getValue: () => value,
|
|
827
|
+
setValue,
|
|
828
|
+
});
|
|
829
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
830
|
+
metadata.sensitivity = 'secret';
|
|
831
|
+
metadata.writable = false;
|
|
832
|
+
const hidden = registry.get(identity);
|
|
833
|
+
expect(hidden?.state).not.toHaveProperty('staged');
|
|
834
|
+
expect(hidden?.state).not.toHaveProperty('stagedValue');
|
|
835
|
+
expect(hidden?.state).not.toHaveProperty('stagedValueRedacted');
|
|
836
|
+
expect(hidden?.metadata.capabilities).not.toEqual(expect.arrayContaining(['stage', 'apply']));
|
|
837
|
+
await expect(dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'discard', identity, revision: 1 }, event))).resolves.toMatchObject({ ok: true, action: 'discard' });
|
|
838
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
839
|
+
expect(registry.get(identity)?.state).not.toHaveProperty('staged');
|
|
840
|
+
metadata.sensitivity = 'public';
|
|
841
|
+
metadata.writable = true;
|
|
842
|
+
expect(registry.get(identity)?.state).not.toHaveProperty('staged');
|
|
843
|
+
});
|
|
844
|
+
it('redacts sensitive current and staged values in snapshots and events', async () => {
|
|
845
|
+
const policyCommands = [];
|
|
846
|
+
const registry = createControlInteractionRegistry({
|
|
847
|
+
policy: (command) => {
|
|
848
|
+
policyCommands.push(command);
|
|
849
|
+
return { allowed: true };
|
|
850
|
+
},
|
|
851
|
+
isLocalGesture: () => true,
|
|
852
|
+
});
|
|
853
|
+
const events = [];
|
|
854
|
+
registry.subscribe((event) => events.push(event));
|
|
855
|
+
registry.register({
|
|
856
|
+
identity,
|
|
857
|
+
metadata: { kind: 'text', sensitivity: 'sensitive' },
|
|
858
|
+
getValue: () => 'private',
|
|
859
|
+
setValue: () => undefined,
|
|
860
|
+
validateValue: (value) => `Do not use ${String(value)}`,
|
|
861
|
+
});
|
|
862
|
+
await registry.execute({ action: 'stage', identity, value: 'replacement' }, { source: 'agent' });
|
|
863
|
+
expect(registry.get(identity)?.state).toMatchObject({
|
|
864
|
+
value: undefined,
|
|
865
|
+
valueRedacted: true,
|
|
866
|
+
stagedValue: undefined,
|
|
867
|
+
stagedValueRedacted: true,
|
|
868
|
+
staged: { value: undefined, valueRedacted: true },
|
|
869
|
+
});
|
|
870
|
+
expect(JSON.stringify(events)).not.toContain('private');
|
|
871
|
+
expect(JSON.stringify(events)).not.toContain('replacement');
|
|
872
|
+
expect(JSON.stringify(policyCommands)).not.toContain('replacement');
|
|
873
|
+
const invalid = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
874
|
+
expect(invalid.reason).toBe('staged_value_invalid');
|
|
875
|
+
expect(JSON.stringify(invalid)).not.toContain('replacement');
|
|
876
|
+
});
|
|
877
|
+
it('redacts sensitive exceptions from results and events', async () => {
|
|
878
|
+
const events = [];
|
|
879
|
+
const registry = createControlInteractionRegistry({
|
|
880
|
+
isLocalGesture: () => true,
|
|
881
|
+
});
|
|
882
|
+
registry.subscribe((event) => events.push(event));
|
|
883
|
+
registry.register({
|
|
884
|
+
identity,
|
|
885
|
+
metadata: { kind: 'text', sensitivity: 'sensitive' },
|
|
886
|
+
getValue: () => 'private',
|
|
887
|
+
setValue: () => {
|
|
888
|
+
throw new Error('could not set replacement');
|
|
889
|
+
},
|
|
890
|
+
validateValue: () => {
|
|
891
|
+
throw new Error('validator saw replacement');
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
const staged = await registry.execute({ action: 'stage', identity, value: 'replacement' }, { source: 'agent' });
|
|
895
|
+
expect(staged).toMatchObject({ ok: false, reason: 'command_failed' });
|
|
896
|
+
expect(JSON.stringify(staged)).not.toContain('replacement');
|
|
897
|
+
expect(JSON.stringify(events)).not.toContain('replacement');
|
|
898
|
+
});
|
|
899
|
+
it('redacts sensitive snapshot failures before policy evaluation', async () => {
|
|
900
|
+
const events = [];
|
|
80
901
|
const registry = createControlInteractionRegistry();
|
|
902
|
+
registry.subscribe((event) => events.push(event));
|
|
81
903
|
registry.register({
|
|
82
904
|
identity,
|
|
83
|
-
metadata: { kind: 'text' },
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
905
|
+
metadata: { kind: 'text', sensitivity: 'sensitive' },
|
|
906
|
+
getValue: () => 'private',
|
|
907
|
+
setValue: () => undefined,
|
|
908
|
+
getState: () => {
|
|
909
|
+
throw new Error('private snapshot details');
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
const outcome = await registry.execute({ action: 'stage', identity, value: 'replacement' }, { source: 'agent' });
|
|
913
|
+
expect(outcome).toMatchObject({ ok: false, reason: 'command_failed' });
|
|
914
|
+
expect(JSON.stringify(outcome)).not.toContain('private snapshot details');
|
|
915
|
+
expect(JSON.stringify(events)).not.toContain('private snapshot details');
|
|
916
|
+
});
|
|
917
|
+
it('restores the prior proposal when replacement staging throws', async () => {
|
|
918
|
+
const registry = createControlInteractionRegistry();
|
|
919
|
+
registry.register({
|
|
920
|
+
identity,
|
|
921
|
+
metadata: { kind: 'text' },
|
|
922
|
+
getValue: () => 'Ada',
|
|
923
|
+
setValue: () => undefined,
|
|
924
|
+
validateValue: (value) => {
|
|
925
|
+
if (value === 'Katherine')
|
|
926
|
+
throw new Error('validator_failed');
|
|
927
|
+
return true;
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
931
|
+
const replacement = await registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
932
|
+
expect(replacement).toMatchObject({
|
|
933
|
+
ok: false,
|
|
934
|
+
reason: 'validator_failed',
|
|
935
|
+
});
|
|
936
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
937
|
+
value: 'Grace',
|
|
938
|
+
revision: 1,
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
it('restores the prior proposal when registration changes during replacement validation', async () => {
|
|
942
|
+
let releaseValidation;
|
|
943
|
+
let validationStarted;
|
|
944
|
+
const validationBlocked = new Promise((resolve) => {
|
|
945
|
+
releaseValidation = resolve;
|
|
946
|
+
});
|
|
947
|
+
const validationStartedPromise = new Promise((resolve) => {
|
|
948
|
+
validationStarted = resolve;
|
|
949
|
+
});
|
|
950
|
+
const registry = createControlInteractionRegistry();
|
|
951
|
+
registry.register({
|
|
952
|
+
identity,
|
|
953
|
+
metadata: { kind: 'text' },
|
|
954
|
+
getValue: () => 'Ada',
|
|
955
|
+
setValue: () => undefined,
|
|
956
|
+
validateValue: async (value) => {
|
|
957
|
+
if (value === 'Katherine') {
|
|
958
|
+
validationStarted?.();
|
|
959
|
+
await validationBlocked;
|
|
960
|
+
}
|
|
961
|
+
return true;
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
965
|
+
const replacement = registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
966
|
+
await validationStartedPromise;
|
|
967
|
+
registry.register({
|
|
968
|
+
identity,
|
|
969
|
+
metadata: { kind: 'text' },
|
|
970
|
+
getValue: () => 'Ada',
|
|
971
|
+
setValue: () => undefined,
|
|
972
|
+
});
|
|
973
|
+
releaseValidation?.();
|
|
974
|
+
expect(await replacement).toMatchObject({
|
|
975
|
+
ok: false,
|
|
976
|
+
reason: 'stale_revision',
|
|
977
|
+
});
|
|
978
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
979
|
+
value: 'Grace',
|
|
980
|
+
revision: 1,
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
it('restores the prior proposal when replacement snapshotting throws', async () => {
|
|
984
|
+
let failNextRead = false;
|
|
985
|
+
const registry = createControlInteractionRegistry();
|
|
986
|
+
registry.register({
|
|
987
|
+
identity,
|
|
988
|
+
metadata: { kind: 'text' },
|
|
989
|
+
getValue: () => {
|
|
990
|
+
if (failNextRead) {
|
|
991
|
+
failNextRead = false;
|
|
992
|
+
throw new Error('snapshot_failed');
|
|
993
|
+
}
|
|
994
|
+
return 'Ada';
|
|
995
|
+
},
|
|
996
|
+
setValue: () => undefined,
|
|
997
|
+
validateValue: (value) => {
|
|
998
|
+
if (value === 'Katherine')
|
|
999
|
+
failNextRead = true;
|
|
1000
|
+
return true;
|
|
1001
|
+
},
|
|
1002
|
+
});
|
|
1003
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1004
|
+
const replacement = await registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
1005
|
+
expect(replacement).toMatchObject({
|
|
1006
|
+
ok: false,
|
|
1007
|
+
reason: 'snapshot_failed',
|
|
1008
|
+
});
|
|
1009
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
1010
|
+
value: 'Grace',
|
|
1011
|
+
revision: 1,
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
it('uses the guarded staged snapshot as the successful command result', async () => {
|
|
1015
|
+
let postValidationReads = 0;
|
|
1016
|
+
let replacing = false;
|
|
1017
|
+
const registry = createControlInteractionRegistry();
|
|
1018
|
+
registry.register({
|
|
1019
|
+
identity,
|
|
1020
|
+
metadata: { kind: 'text' },
|
|
1021
|
+
getValue: () => {
|
|
1022
|
+
if (replacing) {
|
|
1023
|
+
postValidationReads += 1;
|
|
1024
|
+
if (postValidationReads > 2)
|
|
1025
|
+
throw new Error('late_snapshot_failed');
|
|
1026
|
+
}
|
|
1027
|
+
return 'Ada';
|
|
1028
|
+
},
|
|
1029
|
+
setValue: () => undefined,
|
|
1030
|
+
validateValue: (value) => {
|
|
1031
|
+
if (value === 'Katherine')
|
|
1032
|
+
replacing = true;
|
|
1033
|
+
return true;
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1037
|
+
const replacement = await registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
1038
|
+
replacing = false;
|
|
1039
|
+
expect(replacement).toMatchObject({ ok: true });
|
|
1040
|
+
expect(postValidationReads).toBe(2);
|
|
1041
|
+
expect(replacement.snapshot?.state.staged).toMatchObject({
|
|
1042
|
+
value: 'Katherine',
|
|
1043
|
+
revision: 2,
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
it('does not let subscriber failures change committed command results', async () => {
|
|
1047
|
+
const registry = createControlInteractionRegistry();
|
|
1048
|
+
registry.register({
|
|
1049
|
+
identity,
|
|
1050
|
+
metadata: { kind: 'text' },
|
|
1051
|
+
getValue: () => 'Ada',
|
|
1052
|
+
setValue: () => undefined,
|
|
1053
|
+
});
|
|
1054
|
+
registry.subscribe(() => {
|
|
1055
|
+
throw new Error('observer_failed');
|
|
1056
|
+
});
|
|
1057
|
+
const staged = await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1058
|
+
expect(staged.ok).toBe(true);
|
|
1059
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1060
|
+
});
|
|
1061
|
+
it('isolates policy and subscriber objects from executable commands', async () => {
|
|
1062
|
+
let value = 'Ada';
|
|
1063
|
+
const registry = createControlInteractionRegistry({
|
|
1064
|
+
policy: (command) => {
|
|
1065
|
+
command.action = 'apply';
|
|
1066
|
+
return { allowed: true };
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
registry.register({
|
|
1070
|
+
identity,
|
|
1071
|
+
metadata: { kind: 'text' },
|
|
1072
|
+
getValue: () => value,
|
|
1073
|
+
setValue: (next) => {
|
|
1074
|
+
value = String(next);
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
registry.subscribe((event) => {
|
|
1078
|
+
if (event.result) {
|
|
1079
|
+
event.result.ok = false;
|
|
1080
|
+
event.result.reason = 'observer-forged';
|
|
1081
|
+
}
|
|
1082
|
+
event.identity.controlId = 'observer-forged';
|
|
1083
|
+
});
|
|
1084
|
+
const staged = await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1085
|
+
expect(staged).toMatchObject({ ok: true, action: 'stage' });
|
|
1086
|
+
expect(value).toBe('Ada');
|
|
1087
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1088
|
+
});
|
|
1089
|
+
it('redacts sensitive custom-policy denial reasons', async () => {
|
|
1090
|
+
const registry = createControlInteractionRegistry({
|
|
1091
|
+
policy: () => ({ allowed: false, reason: 'private-policy-detail' }),
|
|
1092
|
+
});
|
|
1093
|
+
registry.register({
|
|
1094
|
+
identity,
|
|
1095
|
+
metadata: { kind: 'text', sensitivity: 'sensitive' },
|
|
1096
|
+
getValue: () => 'private',
|
|
1097
|
+
setValue: () => undefined,
|
|
1098
|
+
});
|
|
1099
|
+
expect(await registry.execute({ action: 'stage', identity, value: 'replacement' }, { source: 'agent' })).toMatchObject({ ok: false, reason: 'command_failed' });
|
|
1100
|
+
});
|
|
1101
|
+
it('rejects an awaited same-control mutation invoked by a validator without deadlocking', async () => {
|
|
1102
|
+
let nestedResult = null;
|
|
1103
|
+
let invokedNested = false;
|
|
1104
|
+
const registry = createControlInteractionRegistry();
|
|
1105
|
+
registry.register({
|
|
1106
|
+
identity,
|
|
1107
|
+
metadata: { kind: 'text' },
|
|
1108
|
+
getValue: () => 'Ada',
|
|
1109
|
+
setValue: () => undefined,
|
|
1110
|
+
validateValue: async (_value, extensionContext) => {
|
|
1111
|
+
if (!invokedNested) {
|
|
1112
|
+
invokedNested = true;
|
|
1113
|
+
await Promise.resolve();
|
|
1114
|
+
nestedResult =
|
|
1115
|
+
(await extensionContext?.execute({ action: 'stage', identity, value: 'Nested' }, { source: 'agent' })) ?? null;
|
|
1116
|
+
}
|
|
1117
|
+
return true;
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
let taskRan = false;
|
|
1121
|
+
setTimeout(() => {
|
|
1122
|
+
taskRan = true;
|
|
1123
|
+
}, 0);
|
|
1124
|
+
const outerResult = await registry.execute({ action: 'stage', identity, value: 'Outer' }, { source: 'agent' });
|
|
1125
|
+
expect(nestedResult).toMatchObject({
|
|
1126
|
+
ok: false,
|
|
1127
|
+
reason: 'reentrant_mutation',
|
|
1128
|
+
});
|
|
1129
|
+
expect(outerResult).toMatchObject({ ok: true });
|
|
1130
|
+
expect(taskRan).toBe(false);
|
|
1131
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1132
|
+
expect(taskRan).toBe(true);
|
|
1133
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Outer');
|
|
1134
|
+
});
|
|
1135
|
+
it('redacts extension-context reentrancy failures for sensitive controls', async () => {
|
|
1136
|
+
let nestedResult = null;
|
|
1137
|
+
const registry = createControlInteractionRegistry();
|
|
1138
|
+
registry.register({
|
|
1139
|
+
identity,
|
|
1140
|
+
metadata: { kind: 'text', sensitivity: 'sensitive' },
|
|
1141
|
+
getValue: () => 'private',
|
|
1142
|
+
setValue: () => undefined,
|
|
1143
|
+
validateValue: async (_value, extensionContext) => {
|
|
1144
|
+
nestedResult =
|
|
1145
|
+
(await extensionContext?.execute({ action: 'stage', identity, value: 'nested-private' }, { source: 'agent' })) ?? null;
|
|
1146
|
+
return true;
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
expect(await registry.execute({ action: 'stage', identity, value: 'outer-private' }, { source: 'agent' })).toMatchObject({ ok: true });
|
|
1150
|
+
expect(nestedResult).toMatchObject({
|
|
1151
|
+
ok: false,
|
|
1152
|
+
reason: 'command_failed',
|
|
1153
|
+
});
|
|
1154
|
+
expect(nestedResult?.snapshot?.state).toMatchObject({
|
|
1155
|
+
valueRedacted: true,
|
|
1156
|
+
stagedValueRedacted: true,
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
it.each([
|
|
1160
|
+
'policy',
|
|
1161
|
+
'setValue',
|
|
1162
|
+
'clear',
|
|
1163
|
+
'restoreValue',
|
|
1164
|
+
'validate',
|
|
1165
|
+
])('rejects awaited same-control mutation reentrancy from %s', async (boundary) => {
|
|
1166
|
+
let value = 'Ada';
|
|
1167
|
+
let nestedResult = null;
|
|
1168
|
+
let attempted = false;
|
|
1169
|
+
let registry;
|
|
1170
|
+
const reenter = async (extensionContext) => {
|
|
1171
|
+
if (attempted)
|
|
1172
|
+
return;
|
|
1173
|
+
attempted = true;
|
|
1174
|
+
await Promise.resolve();
|
|
1175
|
+
nestedResult =
|
|
1176
|
+
(await extensionContext?.execute({ action: 'stage', identity, value: 'Nested' }, { source: 'agent' })) ?? null;
|
|
1177
|
+
};
|
|
1178
|
+
registry = createControlInteractionRegistry({
|
|
1179
|
+
isLocalGesture: () => true,
|
|
1180
|
+
policy: boundary === 'policy'
|
|
1181
|
+
? async (_command, _context, _snapshot, extensionContext) => {
|
|
1182
|
+
await reenter(extensionContext);
|
|
1183
|
+
return { allowed: true };
|
|
1184
|
+
}
|
|
1185
|
+
: undefined,
|
|
1186
|
+
});
|
|
1187
|
+
registry.register({
|
|
1188
|
+
identity,
|
|
1189
|
+
metadata: { kind: 'text' },
|
|
1190
|
+
getValue: () => value,
|
|
1191
|
+
setValueWithContext: async (next, extensionContext) => {
|
|
1192
|
+
if (boundary === 'setValue')
|
|
1193
|
+
await reenter(extensionContext);
|
|
1194
|
+
value = String(next);
|
|
1195
|
+
},
|
|
1196
|
+
clear: async (extensionContext) => {
|
|
1197
|
+
value = '';
|
|
1198
|
+
if (boundary === 'clear')
|
|
1199
|
+
await reenter(extensionContext);
|
|
1200
|
+
if (boundary === 'restoreValue')
|
|
1201
|
+
throw new Error('clear_failed');
|
|
1202
|
+
return true;
|
|
1203
|
+
},
|
|
1204
|
+
restoreValue: async (next, extensionContext) => {
|
|
1205
|
+
if (boundary === 'restoreValue')
|
|
1206
|
+
await reenter(extensionContext);
|
|
1207
|
+
value = String(next);
|
|
1208
|
+
},
|
|
1209
|
+
validate: async (extensionContext) => {
|
|
1210
|
+
if (boundary === 'validate')
|
|
1211
|
+
await reenter(extensionContext);
|
|
1212
|
+
return true;
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
1215
|
+
let outerResult;
|
|
1216
|
+
if (boundary === 'policy') {
|
|
1217
|
+
outerResult = await registry.execute({ action: 'stage', identity, value: 'Outer' }, { source: 'agent' });
|
|
1218
|
+
}
|
|
1219
|
+
else if (boundary === 'clear' || boundary === 'restoreValue') {
|
|
1220
|
+
outerResult = await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'clear', identity }, event));
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
await registry.execute({ action: 'stage', identity, value: 'Outer' }, { source: 'agent' });
|
|
1224
|
+
outerResult = await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'apply', identity }, event));
|
|
1225
|
+
}
|
|
1226
|
+
expect(nestedResult).toMatchObject({
|
|
1227
|
+
ok: false,
|
|
1228
|
+
reason: 'reentrant_mutation',
|
|
1229
|
+
});
|
|
1230
|
+
expect(outerResult.ok).toBe(boundary !== 'restoreValue');
|
|
1231
|
+
});
|
|
1232
|
+
it('serializes asynchronous proposals so the newest command wins', async () => {
|
|
1233
|
+
let releaseFirst;
|
|
1234
|
+
let markFirstStarted;
|
|
1235
|
+
const firstValidation = new Promise((resolve) => {
|
|
1236
|
+
releaseFirst = resolve;
|
|
1237
|
+
});
|
|
1238
|
+
const firstStarted = new Promise((resolve) => {
|
|
1239
|
+
markFirstStarted = resolve;
|
|
1240
|
+
});
|
|
1241
|
+
const registry = createControlInteractionRegistry();
|
|
1242
|
+
registry.register({
|
|
1243
|
+
identity,
|
|
1244
|
+
metadata: { kind: 'text' },
|
|
1245
|
+
getValue: () => 'Ada',
|
|
1246
|
+
setValue: () => undefined,
|
|
1247
|
+
validateValue: async (value) => {
|
|
1248
|
+
if (value === 'Grace') {
|
|
1249
|
+
markFirstStarted?.();
|
|
1250
|
+
await firstValidation;
|
|
1251
|
+
}
|
|
1252
|
+
return true;
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1255
|
+
const first = registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1256
|
+
const second = registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
1257
|
+
await firstStarted;
|
|
1258
|
+
releaseFirst?.();
|
|
1259
|
+
expect((await first).ok).toBe(true);
|
|
1260
|
+
expect((await second).ok).toBe(true);
|
|
1261
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
1262
|
+
value: 'Katherine',
|
|
1263
|
+
revision: 2,
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
1266
|
+
it('does not time out an independent command behind a slow same-control extension', async () => {
|
|
1267
|
+
let releaseFirst;
|
|
1268
|
+
let markFirstStarted;
|
|
1269
|
+
const firstValidation = new Promise((resolve) => {
|
|
1270
|
+
releaseFirst = resolve;
|
|
1271
|
+
});
|
|
1272
|
+
const firstStarted = new Promise((resolve) => {
|
|
1273
|
+
markFirstStarted = resolve;
|
|
1274
|
+
});
|
|
1275
|
+
const registry = createControlInteractionRegistry({
|
|
1276
|
+
// Retained as a compatibility no-op. Ordinary queue progress must not
|
|
1277
|
+
// depend on this former deadlock heuristic.
|
|
1278
|
+
reentrantMutationTimeoutMs: 10,
|
|
1279
|
+
});
|
|
1280
|
+
registry.register({
|
|
1281
|
+
identity,
|
|
1282
|
+
metadata: { kind: 'text' },
|
|
1283
|
+
getValue: () => 'Ada',
|
|
1284
|
+
setValue: () => undefined,
|
|
1285
|
+
validateValue: async (value) => {
|
|
1286
|
+
if (value === 'Grace') {
|
|
1287
|
+
markFirstStarted?.();
|
|
1288
|
+
await firstValidation;
|
|
1289
|
+
}
|
|
1290
|
+
return true;
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
const first = registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1294
|
+
await firstStarted;
|
|
1295
|
+
const second = registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
1296
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1297
|
+
releaseFirst?.();
|
|
1298
|
+
expect(await first).toMatchObject({ ok: true });
|
|
1299
|
+
expect(await second).toMatchObject({ ok: true });
|
|
1300
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
1301
|
+
value: 'Katherine',
|
|
1302
|
+
revision: 2,
|
|
1303
|
+
});
|
|
1304
|
+
});
|
|
1305
|
+
it('serializes apply validation against a competing proposal', async () => {
|
|
1306
|
+
let value = 'Ada';
|
|
1307
|
+
let blockApplyValidation = false;
|
|
1308
|
+
let releaseApply;
|
|
1309
|
+
let markApplyStarted;
|
|
1310
|
+
const applyValidation = new Promise((resolve) => {
|
|
1311
|
+
releaseApply = resolve;
|
|
1312
|
+
});
|
|
1313
|
+
const applyStarted = new Promise((resolve) => {
|
|
1314
|
+
markApplyStarted = resolve;
|
|
1315
|
+
});
|
|
1316
|
+
const registry = createControlInteractionRegistry({
|
|
1317
|
+
isLocalGesture: () => true,
|
|
1318
|
+
reentrantMutationTimeoutMs: 20,
|
|
1319
|
+
});
|
|
1320
|
+
registry.register({
|
|
1321
|
+
identity,
|
|
1322
|
+
metadata: { kind: 'text' },
|
|
1323
|
+
getValue: () => value,
|
|
1324
|
+
setValue: (next) => {
|
|
1325
|
+
value = String(next);
|
|
1326
|
+
},
|
|
1327
|
+
validateValue: async (next) => {
|
|
1328
|
+
if (blockApplyValidation && next === 'Grace') {
|
|
1329
|
+
markApplyStarted?.();
|
|
1330
|
+
await applyValidation;
|
|
1331
|
+
}
|
|
1332
|
+
return true;
|
|
1333
|
+
},
|
|
1334
|
+
});
|
|
1335
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1336
|
+
blockApplyValidation = true;
|
|
1337
|
+
const apply = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1338
|
+
await applyStarted;
|
|
1339
|
+
const competingStage = registry.execute({ action: 'stage', identity, value: 'Katherine' }, { source: 'agent' });
|
|
1340
|
+
expect(value).toBe('Ada');
|
|
1341
|
+
releaseApply?.();
|
|
1342
|
+
expect((await apply).ok).toBe(true);
|
|
1343
|
+
expect((await competingStage).ok).toBe(true);
|
|
1344
|
+
expect(value).toBe('Grace');
|
|
1345
|
+
expect(registry.get(identity)?.state.staged).toMatchObject({
|
|
1346
|
+
value: 'Katherine',
|
|
1347
|
+
revision: 2,
|
|
1348
|
+
stale: false,
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
it('keeps a proposal when a control rejects its applied value', async () => {
|
|
1352
|
+
const value = 'Ada';
|
|
1353
|
+
const registry = createControlInteractionRegistry({
|
|
1354
|
+
isLocalGesture: () => true,
|
|
1355
|
+
});
|
|
1356
|
+
registry.register({
|
|
1357
|
+
identity,
|
|
1358
|
+
metadata: { kind: 'text' },
|
|
1359
|
+
getValue: () => value,
|
|
1360
|
+
setValue: () => undefined,
|
|
1361
|
+
});
|
|
1362
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1363
|
+
const applied = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1364
|
+
expect(applied).toMatchObject({
|
|
1365
|
+
ok: false,
|
|
1366
|
+
reason: 'staged_value_rejected',
|
|
1367
|
+
});
|
|
1368
|
+
expect(value).toBe('Ada');
|
|
1369
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1370
|
+
});
|
|
1371
|
+
it('stages the prepared value that a confirmed apply will accept', async () => {
|
|
1372
|
+
let value = 1;
|
|
1373
|
+
const registry = createControlInteractionRegistry({
|
|
1374
|
+
isLocalGesture: () => true,
|
|
1375
|
+
});
|
|
1376
|
+
registry.register({
|
|
1377
|
+
identity,
|
|
1378
|
+
metadata: { kind: 'number' },
|
|
1379
|
+
getValue: () => value,
|
|
1380
|
+
prepareValue: (next) => Number(next),
|
|
1381
|
+
setValue: (next) => {
|
|
1382
|
+
value = Number(next);
|
|
1383
|
+
},
|
|
1384
|
+
});
|
|
1385
|
+
await registry.execute({ action: 'stage', identity, value: '42' }, { source: 'agent' });
|
|
1386
|
+
expect(registry.get(identity)?.state.staged?.value).toBe(42);
|
|
1387
|
+
const applied = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1388
|
+
expect(applied).toMatchObject({ ok: true });
|
|
1389
|
+
expect(value).toBe(42);
|
|
1390
|
+
expect(registry.get(identity)?.state.staged).toBeUndefined();
|
|
1391
|
+
});
|
|
1392
|
+
it('rolls back when a setter applies a value other than the reviewed value', async () => {
|
|
1393
|
+
let value = 'Ada';
|
|
1394
|
+
const registry = createControlInteractionRegistry({
|
|
1395
|
+
isLocalGesture: () => true,
|
|
1396
|
+
});
|
|
1397
|
+
registry.register({
|
|
1398
|
+
identity,
|
|
1399
|
+
metadata: { kind: 'text' },
|
|
1400
|
+
getValue: () => value,
|
|
1401
|
+
setValue: (next) => {
|
|
1402
|
+
value = Number(next);
|
|
1403
|
+
},
|
|
1404
|
+
restoreValue: (next) => {
|
|
1405
|
+
value = next;
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
await registry.execute({ action: 'stage', identity, value: '42' }, { source: 'agent' });
|
|
1409
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent())).toMatchObject({ ok: false, reason: 'staged_value_rejected' });
|
|
1410
|
+
expect(value).toBe('Ada');
|
|
1411
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('42');
|
|
1412
|
+
});
|
|
1413
|
+
it('rolls back a partial mutation when an applied setter throws', async () => {
|
|
1414
|
+
let value = 'Ada';
|
|
1415
|
+
const registry = createControlInteractionRegistry({
|
|
1416
|
+
isLocalGesture: () => true,
|
|
1417
|
+
});
|
|
1418
|
+
registry.register({
|
|
1419
|
+
identity,
|
|
1420
|
+
metadata: { kind: 'text' },
|
|
1421
|
+
getValue: () => value,
|
|
1422
|
+
setValue: (next) => {
|
|
1423
|
+
value = String(next);
|
|
1424
|
+
if (next === 'Grace')
|
|
1425
|
+
throw new Error('setter_failed');
|
|
1426
|
+
},
|
|
1427
|
+
});
|
|
1428
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1429
|
+
const applied = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1430
|
+
expect(applied).toMatchObject({ ok: false, reason: 'setter_failed' });
|
|
1431
|
+
expect(value).toBe('Ada');
|
|
1432
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1433
|
+
});
|
|
1434
|
+
it('does not roll back over a human edit that lands during an async apply', async () => {
|
|
1435
|
+
let value = 'Ada';
|
|
1436
|
+
let userEditRevision = 0;
|
|
1437
|
+
let userEditValue = value;
|
|
1438
|
+
let releaseSetter;
|
|
1439
|
+
const setterBlocked = new Promise((resolve) => {
|
|
1440
|
+
releaseSetter = resolve;
|
|
1441
|
+
});
|
|
1442
|
+
const registry = createControlInteractionRegistry({
|
|
1443
|
+
isLocalGesture: () => true,
|
|
1444
|
+
});
|
|
1445
|
+
registry.register({
|
|
1446
|
+
identity,
|
|
1447
|
+
metadata: { kind: 'text' },
|
|
1448
|
+
getValue: () => value,
|
|
1449
|
+
getUserEditSnapshot: () => ({
|
|
1450
|
+
revision: userEditRevision,
|
|
1451
|
+
value: userEditValue,
|
|
1452
|
+
}),
|
|
1453
|
+
setValue: async (next) => {
|
|
1454
|
+
value = String(next);
|
|
1455
|
+
await setterBlocked;
|
|
1456
|
+
value = 'partial';
|
|
1457
|
+
throw new Error('setter_failed');
|
|
1458
|
+
},
|
|
1459
|
+
restoreValue: (next) => {
|
|
1460
|
+
value = String(next);
|
|
1461
|
+
},
|
|
1462
|
+
});
|
|
1463
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1464
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1465
|
+
await vi.waitFor(() => expect(value).toBe('Grace'));
|
|
1466
|
+
value = 'Katherine';
|
|
1467
|
+
userEditValue = value;
|
|
1468
|
+
userEditRevision += 1;
|
|
1469
|
+
releaseSetter?.();
|
|
1470
|
+
expect(await applying).toMatchObject({
|
|
1471
|
+
ok: false,
|
|
1472
|
+
reason: 'setter_failed',
|
|
1473
|
+
});
|
|
1474
|
+
expect(value).toBe('Katherine');
|
|
1475
|
+
});
|
|
1476
|
+
it('rolls back an asynchronously owned partial setter mutation', async () => {
|
|
1477
|
+
let value = 'Ada';
|
|
1478
|
+
const registry = createControlInteractionRegistry({
|
|
1479
|
+
isLocalGesture: () => true,
|
|
1480
|
+
});
|
|
1481
|
+
registry.register({
|
|
1482
|
+
identity,
|
|
1483
|
+
metadata: { kind: 'text' },
|
|
1484
|
+
getValue: () => value,
|
|
1485
|
+
setValue: async (next) => {
|
|
1486
|
+
if (next === 'Grace') {
|
|
1487
|
+
await Promise.resolve();
|
|
1488
|
+
value = 'partial';
|
|
1489
|
+
throw new Error('setter_failed');
|
|
1490
|
+
}
|
|
1491
|
+
value = String(next);
|
|
1492
|
+
},
|
|
1493
|
+
});
|
|
1494
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1495
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent())).toMatchObject({ ok: false, reason: 'setter_failed' });
|
|
1496
|
+
expect(value).toBe('Ada');
|
|
1497
|
+
});
|
|
1498
|
+
it('rolls back an applied value when live validation throws', async () => {
|
|
1499
|
+
let value = 'Ada';
|
|
1500
|
+
const registry = createControlInteractionRegistry({
|
|
1501
|
+
isLocalGesture: () => true,
|
|
1502
|
+
});
|
|
1503
|
+
registry.register({
|
|
1504
|
+
identity,
|
|
1505
|
+
metadata: { kind: 'text' },
|
|
1506
|
+
getValue: () => value,
|
|
1507
|
+
setValue: (next) => {
|
|
1508
|
+
value = String(next);
|
|
1509
|
+
},
|
|
1510
|
+
validate: () => {
|
|
1511
|
+
throw new Error('live_validation_failed');
|
|
1512
|
+
},
|
|
1513
|
+
});
|
|
1514
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1515
|
+
const applied = await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1516
|
+
expect(applied).toMatchObject({
|
|
1517
|
+
ok: false,
|
|
1518
|
+
reason: 'live_validation_failed',
|
|
1519
|
+
});
|
|
1520
|
+
expect(value).toBe('Ada');
|
|
1521
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1522
|
+
});
|
|
1523
|
+
it('keeps staged and undo state when clear or undo is rejected', async () => {
|
|
1524
|
+
let value = 'Ada';
|
|
1525
|
+
let rejectWrites = false;
|
|
1526
|
+
let rejectUndo = false;
|
|
1527
|
+
const registry = createControlInteractionRegistry({
|
|
1528
|
+
isLocalGesture: () => true,
|
|
1529
|
+
});
|
|
1530
|
+
registry.register({
|
|
1531
|
+
identity,
|
|
1532
|
+
metadata: { kind: 'text' },
|
|
1533
|
+
getValue: () => value,
|
|
1534
|
+
setValue: (next) => {
|
|
1535
|
+
value = rejectUndo ? 'partial' : String(next);
|
|
1536
|
+
},
|
|
1537
|
+
restoreValue: (next) => {
|
|
1538
|
+
value = String(next);
|
|
1539
|
+
},
|
|
1540
|
+
clear: () => {
|
|
1541
|
+
if (!rejectWrites)
|
|
1542
|
+
value = '';
|
|
1543
|
+
},
|
|
1544
|
+
});
|
|
1545
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1546
|
+
rejectWrites = true;
|
|
1547
|
+
const clear = await executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent());
|
|
1548
|
+
expect(clear).toMatchObject({
|
|
1549
|
+
ok: false,
|
|
1550
|
+
reason: 'staged_value_rejected',
|
|
1551
|
+
});
|
|
1552
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1553
|
+
rejectWrites = false;
|
|
1554
|
+
await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1555
|
+
rejectUndo = true;
|
|
1556
|
+
const undo = await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent());
|
|
1557
|
+
expect(undo).toMatchObject({
|
|
1558
|
+
ok: false,
|
|
1559
|
+
reason: 'staged_value_rejected',
|
|
1560
|
+
});
|
|
1561
|
+
expect(value).toBe('Grace');
|
|
1562
|
+
rejectUndo = false;
|
|
1563
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: true });
|
|
1564
|
+
expect(value).toBe('Ada');
|
|
1565
|
+
});
|
|
1566
|
+
it('bounds rollback replay when restoration advances the user-edit revision', async () => {
|
|
1567
|
+
let value = 'Ada';
|
|
1568
|
+
let userEdit = { revision: 0, value };
|
|
1569
|
+
let restoreCalls = 0;
|
|
1570
|
+
const registry = createControlInteractionRegistry({
|
|
1571
|
+
isLocalGesture: () => true,
|
|
1572
|
+
});
|
|
1573
|
+
registry.register({
|
|
1574
|
+
identity,
|
|
1575
|
+
metadata: { kind: 'text' },
|
|
1576
|
+
getValue: () => value,
|
|
1577
|
+
getUserEditSnapshot: () => userEdit,
|
|
1578
|
+
setValue: () => {
|
|
1579
|
+
throw new Error('setter_failed');
|
|
1580
|
+
},
|
|
1581
|
+
clear: () => {
|
|
1582
|
+
value = '';
|
|
1583
|
+
throw new Error('clear_failed');
|
|
1584
|
+
},
|
|
1585
|
+
restoreValue: async (next) => {
|
|
1586
|
+
await Promise.resolve();
|
|
1587
|
+
restoreCalls += 1;
|
|
1588
|
+
if (restoreCalls <= 8) {
|
|
1589
|
+
value = `human-${restoreCalls}`;
|
|
1590
|
+
userEdit = { revision: restoreCalls, value };
|
|
1591
|
+
}
|
|
1592
|
+
await Promise.resolve();
|
|
1593
|
+
value = String(next);
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
const result = await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'clear', identity }, event));
|
|
1597
|
+
expect(result).toMatchObject({ ok: false, reason: 'clear_failed' });
|
|
1598
|
+
expect(restoreCalls).toBe(9);
|
|
1599
|
+
expect(value).toBe('human-8');
|
|
1600
|
+
expect(userEdit).toEqual({ revision: 8, value: 'human-8' });
|
|
1601
|
+
});
|
|
1602
|
+
it('rolls back partial clear and undo mutations while retaining recovery state', async () => {
|
|
1603
|
+
let value = 'Ada';
|
|
1604
|
+
let rejectClear = true;
|
|
1605
|
+
let rejectUndo = false;
|
|
1606
|
+
const registry = createControlInteractionRegistry({
|
|
1607
|
+
isLocalGesture: () => true,
|
|
1608
|
+
});
|
|
1609
|
+
registry.register({
|
|
1610
|
+
identity,
|
|
1611
|
+
metadata: { kind: 'text' },
|
|
1612
|
+
getValue: () => value,
|
|
1613
|
+
setValue: (next) => {
|
|
1614
|
+
value = String(next);
|
|
1615
|
+
if (rejectUndo && next === 'Ada')
|
|
1616
|
+
throw new Error('undo_failed');
|
|
1617
|
+
},
|
|
1618
|
+
clear: () => {
|
|
1619
|
+
value = 'partially-cleared';
|
|
1620
|
+
return !rejectClear;
|
|
1621
|
+
},
|
|
1622
|
+
});
|
|
1623
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1624
|
+
expect(await executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent())).toMatchObject({ ok: false, reason: 'staged_value_rejected' });
|
|
1625
|
+
expect(value).toBe('Ada');
|
|
1626
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1627
|
+
value = 'Ada';
|
|
1628
|
+
rejectClear = false;
|
|
1629
|
+
await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1630
|
+
rejectUndo = true;
|
|
1631
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: false, reason: 'undo_failed' });
|
|
1632
|
+
expect(value).toBe('Grace');
|
|
1633
|
+
rejectUndo = false;
|
|
1634
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: true });
|
|
1635
|
+
expect(value).toBe('Ada');
|
|
1636
|
+
});
|
|
1637
|
+
it.each([
|
|
1638
|
+
{ behavior: 'mutates', reason: 'staged_value_rejected' },
|
|
1639
|
+
{ behavior: 'mutates and throws', reason: 'undo_failed' },
|
|
1640
|
+
])('isolates the undo baseline when an object setter $behavior', async ({ behavior, reason, }) => {
|
|
1641
|
+
let value = { role: 'viewer' };
|
|
1642
|
+
let rejectUndo = false;
|
|
1643
|
+
const registry = createControlInteractionRegistry({
|
|
1644
|
+
isLocalGesture: () => true,
|
|
1645
|
+
});
|
|
1646
|
+
registry.register({
|
|
1647
|
+
identity,
|
|
1648
|
+
metadata: { kind: 'custom' },
|
|
1649
|
+
getValue: () => value,
|
|
1650
|
+
setValue: (candidate) => {
|
|
1651
|
+
const next = candidate;
|
|
1652
|
+
if (rejectUndo && next.role === 'viewer') {
|
|
1653
|
+
next.role = 'owner';
|
|
1654
|
+
value = next;
|
|
1655
|
+
if (behavior === 'mutates and throws') {
|
|
1656
|
+
throw new Error('undo_failed');
|
|
1657
|
+
}
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
value = next;
|
|
1661
|
+
},
|
|
1662
|
+
});
|
|
1663
|
+
await registry.execute({ action: 'stage', identity, value: { role: 'editor' } }, { source: 'agent' });
|
|
1664
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent())).toMatchObject({ ok: true });
|
|
1665
|
+
rejectUndo = true;
|
|
1666
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: false, reason });
|
|
1667
|
+
expect(value).toEqual({ role: 'editor' });
|
|
1668
|
+
rejectUndo = false;
|
|
1669
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: true });
|
|
1670
|
+
expect(value).toEqual({ role: 'viewer' });
|
|
1671
|
+
});
|
|
1672
|
+
it('accepts an explicitly affirmed idempotent clear', async () => {
|
|
1673
|
+
const registry = createControlInteractionRegistry({
|
|
1674
|
+
isLocalGesture: () => true,
|
|
1675
|
+
});
|
|
1676
|
+
registry.register({
|
|
1677
|
+
identity,
|
|
1678
|
+
metadata: { kind: 'slider' },
|
|
1679
|
+
getValue: () => 0,
|
|
1680
|
+
setValue: () => undefined,
|
|
1681
|
+
clear: () => true,
|
|
1682
|
+
});
|
|
1683
|
+
await registry.execute({ action: 'stage', identity, value: 10 }, { source: 'agent' });
|
|
1684
|
+
expect(await executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent())).toMatchObject({ ok: true });
|
|
1685
|
+
expect(registry.get(identity)?.state.staged).toBeUndefined();
|
|
1686
|
+
});
|
|
1687
|
+
it('does not roll back over human edits during asynchronous clear or undo', async () => {
|
|
1688
|
+
let value = 'Ada';
|
|
1689
|
+
let userEditRevision = 0;
|
|
1690
|
+
let userEditValue = value;
|
|
1691
|
+
let releaseClear;
|
|
1692
|
+
let releaseUndo;
|
|
1693
|
+
const clearBlocked = new Promise((resolve) => {
|
|
1694
|
+
releaseClear = resolve;
|
|
1695
|
+
});
|
|
1696
|
+
const undoBlocked = new Promise((resolve) => {
|
|
1697
|
+
releaseUndo = resolve;
|
|
1698
|
+
});
|
|
1699
|
+
let blockUndo = false;
|
|
1700
|
+
const registry = createControlInteractionRegistry({
|
|
1701
|
+
isLocalGesture: () => true,
|
|
1702
|
+
});
|
|
1703
|
+
registry.register({
|
|
1704
|
+
identity,
|
|
1705
|
+
metadata: { kind: 'text' },
|
|
1706
|
+
getValue: () => value,
|
|
1707
|
+
getUserEditSnapshot: () => ({
|
|
1708
|
+
revision: userEditRevision,
|
|
1709
|
+
value: userEditValue,
|
|
1710
|
+
}),
|
|
1711
|
+
setValue: async (next) => {
|
|
1712
|
+
value = String(next);
|
|
1713
|
+
if (blockUndo && next === 'Ada') {
|
|
1714
|
+
await undoBlocked;
|
|
1715
|
+
value = 'partial';
|
|
1716
|
+
throw new Error('undo_failed');
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
restoreValue: (next) => {
|
|
1720
|
+
value = String(next);
|
|
1721
|
+
},
|
|
1722
|
+
clear: async () => {
|
|
1723
|
+
value = '';
|
|
1724
|
+
await clearBlocked;
|
|
1725
|
+
value = 'partial';
|
|
1726
|
+
return false;
|
|
1727
|
+
},
|
|
1728
|
+
});
|
|
1729
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1730
|
+
const clearing = executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent());
|
|
1731
|
+
await vi.waitFor(() => expect(value).toBe(''));
|
|
1732
|
+
value = 'Katherine';
|
|
1733
|
+
userEditValue = value;
|
|
1734
|
+
userEditRevision += 1;
|
|
1735
|
+
releaseClear?.();
|
|
1736
|
+
expect(await clearing).toMatchObject({
|
|
1737
|
+
ok: false,
|
|
1738
|
+
reason: 'staged_value_stale',
|
|
1739
|
+
});
|
|
1740
|
+
expect(value).toBe('Katherine');
|
|
1741
|
+
value = 'Ada';
|
|
1742
|
+
await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1743
|
+
blockUndo = true;
|
|
1744
|
+
const undoing = executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent());
|
|
1745
|
+
await vi.waitFor(() => expect(value).toBe('Ada'));
|
|
1746
|
+
value = 'Katherine';
|
|
1747
|
+
userEditValue = value;
|
|
1748
|
+
userEditRevision += 1;
|
|
1749
|
+
releaseUndo?.();
|
|
1750
|
+
expect(await undoing).toMatchObject({ ok: false, reason: 'undo_failed' });
|
|
1751
|
+
expect(value).toBe('Katherine');
|
|
1752
|
+
});
|
|
1753
|
+
it('rolls back an asynchronously owned partial clear mutation', async () => {
|
|
1754
|
+
let value = 'Ada';
|
|
1755
|
+
const registry = createControlInteractionRegistry({
|
|
1756
|
+
isLocalGesture: () => true,
|
|
1757
|
+
});
|
|
1758
|
+
registry.register({
|
|
1759
|
+
identity,
|
|
1760
|
+
metadata: { kind: 'text' },
|
|
1761
|
+
getValue: () => value,
|
|
1762
|
+
setValue: (next) => {
|
|
1763
|
+
value = String(next);
|
|
1764
|
+
},
|
|
1765
|
+
clear: async () => {
|
|
1766
|
+
await Promise.resolve();
|
|
1767
|
+
value = 'partial';
|
|
1768
|
+
return false;
|
|
1769
|
+
},
|
|
1770
|
+
});
|
|
1771
|
+
expect(await executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent())).toMatchObject({ ok: false, reason: 'staged_value_rejected' });
|
|
1772
|
+
expect(value).toBe('Ada');
|
|
1773
|
+
});
|
|
1774
|
+
it('preserves an intervening human edit instead of applying stale undo history', async () => {
|
|
1775
|
+
let value = 'Ada';
|
|
1776
|
+
const registry = createControlInteractionRegistry({
|
|
1777
|
+
isLocalGesture: () => true,
|
|
1778
|
+
});
|
|
1779
|
+
registry.register({
|
|
1780
|
+
identity,
|
|
1781
|
+
metadata: { kind: 'text' },
|
|
1782
|
+
getValue: () => value,
|
|
1783
|
+
setValue: (next) => {
|
|
1784
|
+
value = String(next);
|
|
1785
|
+
},
|
|
1786
|
+
});
|
|
1787
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1788
|
+
await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1789
|
+
value = 'Katherine';
|
|
1790
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: false, reason: 'staged_value_stale' });
|
|
1791
|
+
expect(value).toBe('Katherine');
|
|
1792
|
+
});
|
|
1793
|
+
it('honors an explicit clear rejection for an already-empty control', async () => {
|
|
1794
|
+
let value = '';
|
|
1795
|
+
const registry = createControlInteractionRegistry({
|
|
1796
|
+
isLocalGesture: () => true,
|
|
1797
|
+
});
|
|
1798
|
+
registry.register({
|
|
1799
|
+
identity,
|
|
1800
|
+
metadata: { kind: 'text' },
|
|
1801
|
+
getValue: () => value,
|
|
1802
|
+
setValue: (next) => {
|
|
1803
|
+
value = String(next);
|
|
1804
|
+
},
|
|
1805
|
+
clear: () => false,
|
|
1806
|
+
});
|
|
1807
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1808
|
+
expect(await executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent())).toMatchObject({ ok: false, reason: 'staged_value_rejected' });
|
|
1809
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1810
|
+
});
|
|
1811
|
+
it('preserves a same-value human edit made during async validation', async () => {
|
|
1812
|
+
let value = 'Ada';
|
|
1813
|
+
let userEditValue = value;
|
|
1814
|
+
let userEditRevision = 0;
|
|
1815
|
+
let releaseValidation;
|
|
1816
|
+
let validationStarted;
|
|
1817
|
+
const validationBlocked = new Promise((resolve) => {
|
|
1818
|
+
releaseValidation = resolve;
|
|
1819
|
+
});
|
|
1820
|
+
const validationStartedPromise = new Promise((resolve) => {
|
|
1821
|
+
validationStarted = resolve;
|
|
1822
|
+
});
|
|
1823
|
+
const registry = createControlInteractionRegistry({
|
|
1824
|
+
isLocalGesture: () => true,
|
|
1825
|
+
});
|
|
1826
|
+
registry.register({
|
|
1827
|
+
identity,
|
|
1828
|
+
metadata: { kind: 'text' },
|
|
1829
|
+
getValue: () => value,
|
|
1830
|
+
getUserEditSnapshot: () => ({
|
|
1831
|
+
revision: userEditRevision,
|
|
1832
|
+
value: userEditValue,
|
|
1833
|
+
}),
|
|
1834
|
+
setValue: (next) => {
|
|
1835
|
+
value = String(next);
|
|
1836
|
+
},
|
|
1837
|
+
restoreValue: (next) => {
|
|
1838
|
+
value = String(next);
|
|
1839
|
+
},
|
|
1840
|
+
validate: async () => {
|
|
1841
|
+
validationStarted?.();
|
|
1842
|
+
await validationBlocked;
|
|
1843
|
+
return false;
|
|
1844
|
+
},
|
|
1845
|
+
});
|
|
1846
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1847
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1848
|
+
await validationStartedPromise;
|
|
1849
|
+
value = 'Grace';
|
|
1850
|
+
userEditValue = value;
|
|
1851
|
+
userEditRevision += 1;
|
|
1852
|
+
releaseValidation?.();
|
|
1853
|
+
expect(await applying).toMatchObject({
|
|
1854
|
+
ok: false,
|
|
1855
|
+
reason: 'staged_value_stale',
|
|
1856
|
+
});
|
|
1857
|
+
expect(value).toBe('Grace');
|
|
1858
|
+
});
|
|
1859
|
+
it('replays a human edit that occurs while async restoration is pending', async () => {
|
|
1860
|
+
let value = 'Ada';
|
|
1861
|
+
let userEditValue = value;
|
|
1862
|
+
let userEditRevision = 0;
|
|
1863
|
+
let releaseRestore;
|
|
1864
|
+
let restoreStarted;
|
|
1865
|
+
const restoreBlocked = new Promise((resolve) => {
|
|
1866
|
+
releaseRestore = resolve;
|
|
1867
|
+
});
|
|
1868
|
+
const restoreStartedPromise = new Promise((resolve) => {
|
|
1869
|
+
restoreStarted = resolve;
|
|
1870
|
+
});
|
|
1871
|
+
const restoreValue = vi.fn(async (next) => {
|
|
1872
|
+
restoreStarted?.();
|
|
1873
|
+
await restoreBlocked;
|
|
1874
|
+
value = String(next);
|
|
1875
|
+
});
|
|
1876
|
+
const registry = createControlInteractionRegistry({
|
|
1877
|
+
isLocalGesture: () => true,
|
|
1878
|
+
});
|
|
1879
|
+
registry.register({
|
|
1880
|
+
identity,
|
|
1881
|
+
metadata: { kind: 'text' },
|
|
1882
|
+
getValue: () => value,
|
|
1883
|
+
getUserEditSnapshot: () => ({
|
|
1884
|
+
revision: userEditRevision,
|
|
1885
|
+
value: userEditValue,
|
|
1886
|
+
}),
|
|
1887
|
+
setValue: (next) => {
|
|
1888
|
+
value = String(next);
|
|
1889
|
+
throw new Error('setter_failed');
|
|
1890
|
+
},
|
|
1891
|
+
restoreValue,
|
|
1892
|
+
});
|
|
1893
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1894
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1895
|
+
await restoreStartedPromise;
|
|
1896
|
+
value = 'Katherine';
|
|
1897
|
+
userEditValue = value;
|
|
1898
|
+
userEditRevision += 1;
|
|
1899
|
+
releaseRestore?.();
|
|
1900
|
+
expect(await applying).toMatchObject({
|
|
1901
|
+
ok: false,
|
|
1902
|
+
reason: 'setter_failed',
|
|
1903
|
+
});
|
|
1904
|
+
expect(value).toBe('Katherine');
|
|
1905
|
+
expect(restoreValue).toHaveBeenCalledTimes(2);
|
|
1906
|
+
});
|
|
1907
|
+
it('does not commit a successful async apply over a newer human edit', async () => {
|
|
1908
|
+
let value = 'Ada';
|
|
1909
|
+
let userEditValue = value;
|
|
1910
|
+
let userEditRevision = 0;
|
|
1911
|
+
let releaseSetter;
|
|
1912
|
+
let setterStarted;
|
|
1913
|
+
const setterBlocked = new Promise((resolve) => {
|
|
1914
|
+
releaseSetter = resolve;
|
|
1915
|
+
});
|
|
1916
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
1917
|
+
setterStarted = resolve;
|
|
1918
|
+
});
|
|
1919
|
+
const registry = createControlInteractionRegistry({
|
|
1920
|
+
isLocalGesture: () => true,
|
|
1921
|
+
});
|
|
1922
|
+
registry.register({
|
|
1923
|
+
identity,
|
|
1924
|
+
metadata: { kind: 'text' },
|
|
1925
|
+
getValue: () => value,
|
|
1926
|
+
getUserEditSnapshot: () => ({
|
|
1927
|
+
revision: userEditRevision,
|
|
1928
|
+
value: userEditValue,
|
|
1929
|
+
}),
|
|
1930
|
+
setValue: async (next) => {
|
|
1931
|
+
setterStarted?.();
|
|
1932
|
+
await setterBlocked;
|
|
1933
|
+
value = String(next);
|
|
1934
|
+
},
|
|
1935
|
+
restoreValue: (next) => {
|
|
1936
|
+
value = String(next);
|
|
1937
|
+
},
|
|
1938
|
+
});
|
|
1939
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1940
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1941
|
+
await setterStartedPromise;
|
|
1942
|
+
value = 'Katherine';
|
|
1943
|
+
userEditValue = value;
|
|
1944
|
+
userEditRevision += 1;
|
|
1945
|
+
releaseSetter?.();
|
|
1946
|
+
expect(await applying).toMatchObject({
|
|
1947
|
+
ok: false,
|
|
1948
|
+
reason: 'staged_value_stale',
|
|
1949
|
+
});
|
|
1950
|
+
expect(value).toBe('Katherine');
|
|
1951
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
1952
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: false, reason: 'nothing_to_undo' });
|
|
1953
|
+
});
|
|
1954
|
+
it('rejects a newer human edit made during async proposal validation', async () => {
|
|
1955
|
+
let value = 'Ada';
|
|
1956
|
+
let userEditValue = value;
|
|
1957
|
+
let userEditRevision = 0;
|
|
1958
|
+
let releaseValidation;
|
|
1959
|
+
let validationStarted;
|
|
1960
|
+
const validationBlocked = new Promise((resolve) => {
|
|
1961
|
+
releaseValidation = resolve;
|
|
1962
|
+
});
|
|
1963
|
+
const validationStartedPromise = new Promise((resolve) => {
|
|
1964
|
+
validationStarted = resolve;
|
|
1965
|
+
});
|
|
1966
|
+
let blockValidation = false;
|
|
1967
|
+
const setValue = vi.fn((next) => {
|
|
1968
|
+
value = String(next);
|
|
1969
|
+
});
|
|
1970
|
+
const registry = createControlInteractionRegistry({
|
|
1971
|
+
isLocalGesture: () => true,
|
|
1972
|
+
});
|
|
1973
|
+
registry.register({
|
|
1974
|
+
identity,
|
|
1975
|
+
metadata: { kind: 'text' },
|
|
1976
|
+
getValue: () => value,
|
|
1977
|
+
getUserEditSnapshot: () => ({
|
|
1978
|
+
revision: userEditRevision,
|
|
1979
|
+
value: userEditValue,
|
|
1980
|
+
}),
|
|
1981
|
+
setValue,
|
|
1982
|
+
validateValue: async () => {
|
|
1983
|
+
if (blockValidation) {
|
|
1984
|
+
validationStarted?.();
|
|
1985
|
+
await validationBlocked;
|
|
1986
|
+
}
|
|
1987
|
+
return true;
|
|
1988
|
+
},
|
|
1989
|
+
});
|
|
1990
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
1991
|
+
blockValidation = true;
|
|
1992
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
1993
|
+
await validationStartedPromise;
|
|
1994
|
+
userEditValue = value;
|
|
1995
|
+
userEditRevision += 1;
|
|
1996
|
+
releaseValidation?.();
|
|
1997
|
+
expect(await applying).toMatchObject({
|
|
1998
|
+
ok: false,
|
|
1999
|
+
reason: 'staged_value_stale',
|
|
2000
|
+
});
|
|
2001
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
2002
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
2003
|
+
});
|
|
2004
|
+
it('rejects newer human edits made during async clear and undo policy', async () => {
|
|
2005
|
+
let value = 'Ada';
|
|
2006
|
+
let userEditValue = value;
|
|
2007
|
+
let userEditRevision = 0;
|
|
2008
|
+
let blockedAction;
|
|
2009
|
+
let releasePolicy;
|
|
2010
|
+
let policyStarted;
|
|
2011
|
+
let policyBlocked = Promise.resolve();
|
|
2012
|
+
const blockPolicy = (action) => {
|
|
2013
|
+
blockedAction = action;
|
|
2014
|
+
policyBlocked = new Promise((resolve) => {
|
|
2015
|
+
releasePolicy = resolve;
|
|
2016
|
+
});
|
|
2017
|
+
return new Promise((resolve) => {
|
|
2018
|
+
policyStarted = resolve;
|
|
2019
|
+
});
|
|
2020
|
+
};
|
|
2021
|
+
const setValue = vi.fn((next) => {
|
|
2022
|
+
value = String(next);
|
|
2023
|
+
});
|
|
2024
|
+
const clear = vi.fn(() => {
|
|
2025
|
+
value = '';
|
|
2026
|
+
return true;
|
|
2027
|
+
});
|
|
2028
|
+
const registry = createControlInteractionRegistry({
|
|
2029
|
+
isLocalGesture: () => true,
|
|
2030
|
+
policy: async (command) => {
|
|
2031
|
+
if (command.action === blockedAction) {
|
|
2032
|
+
policyStarted?.();
|
|
2033
|
+
await policyBlocked;
|
|
2034
|
+
}
|
|
2035
|
+
return { allowed: true };
|
|
2036
|
+
},
|
|
2037
|
+
});
|
|
2038
|
+
registry.register({
|
|
2039
|
+
identity,
|
|
2040
|
+
metadata: { kind: 'text' },
|
|
2041
|
+
getValue: () => value,
|
|
2042
|
+
getUserEditSnapshot: () => ({
|
|
2043
|
+
revision: userEditRevision,
|
|
2044
|
+
value: userEditValue,
|
|
2045
|
+
}),
|
|
2046
|
+
setValue,
|
|
2047
|
+
clear,
|
|
2048
|
+
});
|
|
2049
|
+
const clearStarted = blockPolicy('clear');
|
|
2050
|
+
const clearing = executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent());
|
|
2051
|
+
await clearStarted;
|
|
2052
|
+
userEditValue = value;
|
|
2053
|
+
userEditRevision += 1;
|
|
2054
|
+
releasePolicy?.();
|
|
2055
|
+
expect(await clearing).toMatchObject({
|
|
2056
|
+
ok: false,
|
|
2057
|
+
reason: 'staged_value_stale',
|
|
2058
|
+
});
|
|
2059
|
+
expect(clear).not.toHaveBeenCalled();
|
|
2060
|
+
blockedAction = undefined;
|
|
2061
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2062
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent())).toMatchObject({ ok: true });
|
|
2063
|
+
setValue.mockClear();
|
|
2064
|
+
const undoStarted = blockPolicy('undo');
|
|
2065
|
+
const undoing = executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent());
|
|
2066
|
+
await undoStarted;
|
|
2067
|
+
userEditValue = value;
|
|
2068
|
+
userEditRevision += 1;
|
|
2069
|
+
releasePolicy?.();
|
|
2070
|
+
expect(await undoing).toMatchObject({
|
|
2071
|
+
ok: false,
|
|
2072
|
+
reason: 'staged_value_stale',
|
|
2073
|
+
});
|
|
2074
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
2075
|
+
});
|
|
2076
|
+
it('does not commit an async apply after its registration is replaced', async () => {
|
|
2077
|
+
let originalValue = 'Ada';
|
|
2078
|
+
let replacementValue = 'Katherine';
|
|
2079
|
+
let releaseSetter;
|
|
2080
|
+
let setterStarted;
|
|
2081
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2082
|
+
releaseSetter = resolve;
|
|
2083
|
+
});
|
|
2084
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2085
|
+
setterStarted = resolve;
|
|
2086
|
+
});
|
|
2087
|
+
const registry = createControlInteractionRegistry({
|
|
2088
|
+
isLocalGesture: () => true,
|
|
2089
|
+
});
|
|
2090
|
+
registry.register({
|
|
2091
|
+
identity,
|
|
2092
|
+
metadata: { kind: 'text' },
|
|
2093
|
+
getValue: () => originalValue,
|
|
2094
|
+
setValue: async (next) => {
|
|
2095
|
+
setterStarted?.();
|
|
2096
|
+
await setterBlocked;
|
|
2097
|
+
originalValue = String(next);
|
|
2098
|
+
},
|
|
2099
|
+
});
|
|
2100
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2101
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2102
|
+
await setterStartedPromise;
|
|
2103
|
+
registry.register({
|
|
2104
|
+
identity,
|
|
2105
|
+
metadata: { kind: 'text' },
|
|
2106
|
+
getValue: () => replacementValue,
|
|
2107
|
+
setValue: (next) => {
|
|
2108
|
+
replacementValue = String(next);
|
|
2109
|
+
},
|
|
2110
|
+
});
|
|
2111
|
+
releaseSetter?.();
|
|
2112
|
+
expect(await applying).toMatchObject({
|
|
2113
|
+
ok: false,
|
|
2114
|
+
reason: 'staged_value_stale',
|
|
2115
|
+
});
|
|
2116
|
+
expect(replacementValue).toBe('Katherine');
|
|
2117
|
+
});
|
|
2118
|
+
it('reconciles a superseded async registration through the shared current model', async () => {
|
|
2119
|
+
let value = 'Ada';
|
|
2120
|
+
let releaseSetter;
|
|
2121
|
+
let setterStarted;
|
|
2122
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2123
|
+
releaseSetter = resolve;
|
|
2124
|
+
});
|
|
2125
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2126
|
+
setterStarted = resolve;
|
|
2127
|
+
});
|
|
2128
|
+
const registry = createControlInteractionRegistry({
|
|
2129
|
+
isLocalGesture: () => true,
|
|
2130
|
+
});
|
|
2131
|
+
registry.register({
|
|
2132
|
+
identity,
|
|
2133
|
+
metadata: { kind: 'text' },
|
|
2134
|
+
getValue: () => value,
|
|
2135
|
+
setValue: async (next) => {
|
|
2136
|
+
setterStarted?.();
|
|
2137
|
+
await setterBlocked;
|
|
2138
|
+
value = String(next);
|
|
2139
|
+
},
|
|
2140
|
+
});
|
|
2141
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2142
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2143
|
+
await setterStartedPromise;
|
|
2144
|
+
value = 'Katherine';
|
|
2145
|
+
registry.register({
|
|
2146
|
+
identity,
|
|
2147
|
+
metadata: { kind: 'text' },
|
|
2148
|
+
getValue: () => value,
|
|
2149
|
+
setValue: (next) => {
|
|
2150
|
+
value = String(next);
|
|
2151
|
+
},
|
|
2152
|
+
restoreValue: (next) => {
|
|
2153
|
+
value = String(next);
|
|
2154
|
+
},
|
|
2155
|
+
});
|
|
2156
|
+
releaseSetter?.();
|
|
2157
|
+
expect(await applying).toMatchObject({
|
|
2158
|
+
ok: false,
|
|
2159
|
+
reason: 'staged_value_stale',
|
|
2160
|
+
});
|
|
2161
|
+
expect(value).toBe('Katherine');
|
|
2162
|
+
});
|
|
2163
|
+
it('preserves the first human edit recorded after a replacement registers', async () => {
|
|
2164
|
+
let value = 'Ada';
|
|
2165
|
+
let releaseSetter;
|
|
2166
|
+
let setterStarted;
|
|
2167
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2168
|
+
releaseSetter = resolve;
|
|
2169
|
+
});
|
|
2170
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2171
|
+
setterStarted = resolve;
|
|
2172
|
+
});
|
|
2173
|
+
const registry = createControlInteractionRegistry({
|
|
2174
|
+
isLocalGesture: () => true,
|
|
2175
|
+
});
|
|
2176
|
+
registry.register({
|
|
2177
|
+
identity,
|
|
2178
|
+
metadata: { kind: 'text' },
|
|
2179
|
+
getValue: () => value,
|
|
2180
|
+
setValue: async (next) => {
|
|
2181
|
+
setterStarted?.();
|
|
2182
|
+
await setterBlocked;
|
|
2183
|
+
value = String(next);
|
|
2184
|
+
},
|
|
2185
|
+
});
|
|
2186
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2187
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2188
|
+
await setterStartedPromise;
|
|
2189
|
+
value = 'Katherine';
|
|
2190
|
+
registry.register({
|
|
2191
|
+
identity,
|
|
2192
|
+
metadata: { kind: 'text' },
|
|
2193
|
+
getValue: () => value,
|
|
2194
|
+
setValue: (next) => {
|
|
2195
|
+
value = String(next);
|
|
2196
|
+
},
|
|
2197
|
+
restoreValue: (next) => {
|
|
2198
|
+
value = String(next);
|
|
2199
|
+
},
|
|
2200
|
+
});
|
|
2201
|
+
value = 'Margaret';
|
|
2202
|
+
registry.recordUserEdit?.(identity);
|
|
2203
|
+
releaseSetter?.();
|
|
2204
|
+
expect(await applying).toMatchObject({
|
|
2205
|
+
ok: false,
|
|
2206
|
+
reason: 'staged_value_stale',
|
|
2207
|
+
});
|
|
2208
|
+
expect(value).toBe('Margaret');
|
|
2209
|
+
});
|
|
2210
|
+
it('captures a replacement baseline before an already-released setter resumes', async () => {
|
|
2211
|
+
let value = 'Ada';
|
|
2212
|
+
let releaseSetter;
|
|
2213
|
+
let setterStarted;
|
|
2214
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2215
|
+
releaseSetter = resolve;
|
|
2216
|
+
});
|
|
2217
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2218
|
+
setterStarted = resolve;
|
|
2219
|
+
});
|
|
2220
|
+
const registry = createControlInteractionRegistry({
|
|
2221
|
+
isLocalGesture: () => true,
|
|
2222
|
+
});
|
|
2223
|
+
registry.register({
|
|
2224
|
+
identity,
|
|
2225
|
+
metadata: { kind: 'text' },
|
|
2226
|
+
getValue: () => value,
|
|
2227
|
+
setValue: async (next) => {
|
|
2228
|
+
setterStarted?.();
|
|
2229
|
+
await setterBlocked;
|
|
2230
|
+
value = String(next);
|
|
2231
|
+
},
|
|
2232
|
+
});
|
|
2233
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2234
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2235
|
+
await setterStartedPromise;
|
|
2236
|
+
value = 'Katherine';
|
|
2237
|
+
releaseSetter?.();
|
|
2238
|
+
registry.register({
|
|
2239
|
+
identity,
|
|
2240
|
+
metadata: { kind: 'text' },
|
|
2241
|
+
getValue: () => value,
|
|
2242
|
+
setValue: (next) => {
|
|
2243
|
+
value = String(next);
|
|
2244
|
+
},
|
|
2245
|
+
restoreValue: (next) => {
|
|
2246
|
+
value = String(next);
|
|
2247
|
+
},
|
|
2248
|
+
});
|
|
2249
|
+
expect(await applying).toMatchObject({
|
|
2250
|
+
ok: false,
|
|
2251
|
+
reason: 'staged_value_stale',
|
|
2252
|
+
});
|
|
2253
|
+
expect(value).toBe('Katherine');
|
|
2254
|
+
});
|
|
2255
|
+
it('reconciles a throwing superseded setter through the current registration', async () => {
|
|
2256
|
+
let value = 'Ada';
|
|
2257
|
+
let releaseSetter;
|
|
2258
|
+
let setterStarted;
|
|
2259
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2260
|
+
releaseSetter = resolve;
|
|
2261
|
+
});
|
|
2262
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2263
|
+
setterStarted = resolve;
|
|
2264
|
+
});
|
|
2265
|
+
const registry = createControlInteractionRegistry({
|
|
2266
|
+
isLocalGesture: () => true,
|
|
2267
|
+
});
|
|
2268
|
+
registry.register({
|
|
2269
|
+
identity,
|
|
2270
|
+
metadata: { kind: 'text' },
|
|
2271
|
+
getValue: () => value,
|
|
2272
|
+
setValue: async () => {
|
|
2273
|
+
setterStarted?.();
|
|
2274
|
+
await setterBlocked;
|
|
2275
|
+
value = 'partial';
|
|
2276
|
+
throw new Error('setter_failed');
|
|
2277
|
+
},
|
|
2278
|
+
});
|
|
2279
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2280
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2281
|
+
await setterStartedPromise;
|
|
2282
|
+
value = 'Katherine';
|
|
2283
|
+
registry.register({
|
|
2284
|
+
identity,
|
|
2285
|
+
metadata: { kind: 'text' },
|
|
2286
|
+
getValue: () => value,
|
|
2287
|
+
setValue: (next) => {
|
|
2288
|
+
value = String(next);
|
|
2289
|
+
},
|
|
2290
|
+
restoreValue: (next) => {
|
|
2291
|
+
value = String(next);
|
|
2292
|
+
},
|
|
2293
|
+
});
|
|
2294
|
+
releaseSetter?.();
|
|
2295
|
+
expect(await applying).toMatchObject({
|
|
2296
|
+
ok: false,
|
|
2297
|
+
reason: 'setter_failed',
|
|
2298
|
+
});
|
|
2299
|
+
expect(value).toBe('Katherine');
|
|
2300
|
+
});
|
|
2301
|
+
it('does not preserve a synchronous partial setter value as a replacement baseline', async () => {
|
|
2302
|
+
let value = 'Ada';
|
|
2303
|
+
let releaseSetter;
|
|
2304
|
+
let setterStarted;
|
|
2305
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2306
|
+
releaseSetter = resolve;
|
|
2307
|
+
});
|
|
2308
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2309
|
+
setterStarted = resolve;
|
|
2310
|
+
});
|
|
2311
|
+
const registry = createControlInteractionRegistry({
|
|
2312
|
+
isLocalGesture: () => true,
|
|
2313
|
+
});
|
|
2314
|
+
registry.register({
|
|
2315
|
+
identity,
|
|
2316
|
+
metadata: { kind: 'text' },
|
|
2317
|
+
getValue: () => value,
|
|
2318
|
+
setValue: async () => {
|
|
2319
|
+
value = 'partial';
|
|
2320
|
+
setterStarted?.();
|
|
2321
|
+
await setterBlocked;
|
|
2322
|
+
throw new Error('setter_failed');
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2326
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2327
|
+
await setterStartedPromise;
|
|
2328
|
+
registry.register({
|
|
2329
|
+
identity,
|
|
2330
|
+
metadata: { kind: 'text' },
|
|
2331
|
+
getValue: () => value,
|
|
2332
|
+
setValue: (next) => {
|
|
2333
|
+
value = String(next);
|
|
2334
|
+
},
|
|
2335
|
+
restoreValue: (next) => {
|
|
2336
|
+
value = String(next);
|
|
2337
|
+
},
|
|
2338
|
+
});
|
|
2339
|
+
releaseSetter?.();
|
|
2340
|
+
expect(await applying).toMatchObject({
|
|
2341
|
+
ok: false,
|
|
2342
|
+
reason: 'setter_failed',
|
|
2343
|
+
});
|
|
2344
|
+
expect(value).toBe('Ada');
|
|
2345
|
+
});
|
|
2346
|
+
it('preserves a newer replacement user edit that equals the partial setter value', async () => {
|
|
2347
|
+
let value = 'Ada';
|
|
2348
|
+
let releaseSetter;
|
|
2349
|
+
let setterStarted;
|
|
2350
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2351
|
+
releaseSetter = resolve;
|
|
2352
|
+
});
|
|
2353
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2354
|
+
setterStarted = resolve;
|
|
2355
|
+
});
|
|
2356
|
+
const registry = createControlInteractionRegistry({
|
|
2357
|
+
isLocalGesture: () => true,
|
|
2358
|
+
});
|
|
2359
|
+
registry.register({
|
|
2360
|
+
identity,
|
|
2361
|
+
metadata: { kind: 'text' },
|
|
2362
|
+
getValue: () => value,
|
|
2363
|
+
getUserEditSnapshot: () => ({ revision: 0, value }),
|
|
2364
|
+
setValue: async () => {
|
|
2365
|
+
value = 'partial';
|
|
2366
|
+
setterStarted?.();
|
|
2367
|
+
await setterBlocked;
|
|
2368
|
+
throw new Error('setter_failed');
|
|
2369
|
+
},
|
|
2370
|
+
});
|
|
2371
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2372
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2373
|
+
await setterStartedPromise;
|
|
2374
|
+
registry.register({
|
|
2375
|
+
identity,
|
|
2376
|
+
metadata: { kind: 'text' },
|
|
2377
|
+
getValue: () => value,
|
|
2378
|
+
getUserEditSnapshot: () => ({ revision: 1, value }),
|
|
2379
|
+
setValue: (next) => {
|
|
2380
|
+
value = String(next);
|
|
2381
|
+
},
|
|
2382
|
+
restoreValue: (next) => {
|
|
2383
|
+
value = String(next);
|
|
2384
|
+
},
|
|
2385
|
+
});
|
|
2386
|
+
releaseSetter?.();
|
|
2387
|
+
expect(await applying).toMatchObject({
|
|
2388
|
+
ok: false,
|
|
2389
|
+
reason: 'setter_failed',
|
|
2390
|
+
});
|
|
2391
|
+
expect(value).toBe('partial');
|
|
2392
|
+
});
|
|
2393
|
+
it('replays a null human edit made during replacement restoration', async () => {
|
|
2394
|
+
let value = 'Ada';
|
|
2395
|
+
let releaseSetter;
|
|
2396
|
+
let setterStarted;
|
|
2397
|
+
let releaseRestore;
|
|
2398
|
+
let restoreStarted;
|
|
2399
|
+
const setterBlocked = new Promise((resolve) => {
|
|
2400
|
+
releaseSetter = resolve;
|
|
2401
|
+
});
|
|
2402
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
2403
|
+
setterStarted = resolve;
|
|
2404
|
+
});
|
|
2405
|
+
const restoreBlocked = new Promise((resolve) => {
|
|
2406
|
+
releaseRestore = resolve;
|
|
2407
|
+
});
|
|
2408
|
+
const restoreStartedPromise = new Promise((resolve) => {
|
|
2409
|
+
restoreStarted = resolve;
|
|
2410
|
+
});
|
|
2411
|
+
let restoreCalls = 0;
|
|
2412
|
+
const registry = createControlInteractionRegistry({
|
|
2413
|
+
isLocalGesture: () => true,
|
|
2414
|
+
});
|
|
2415
|
+
registry.register({
|
|
2416
|
+
identity,
|
|
2417
|
+
metadata: { kind: 'text' },
|
|
2418
|
+
getValue: () => value,
|
|
2419
|
+
setValue: async (next) => {
|
|
2420
|
+
setterStarted?.();
|
|
2421
|
+
await setterBlocked;
|
|
2422
|
+
value = String(next);
|
|
2423
|
+
},
|
|
2424
|
+
});
|
|
2425
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2426
|
+
const applying = executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent());
|
|
2427
|
+
await setterStartedPromise;
|
|
2428
|
+
registry.register({
|
|
2429
|
+
identity,
|
|
2430
|
+
metadata: { kind: 'text' },
|
|
2431
|
+
getValue: () => value,
|
|
2432
|
+
setValue: (next) => {
|
|
2433
|
+
value = next === null ? null : String(next);
|
|
2434
|
+
},
|
|
2435
|
+
restoreValue: async (next) => {
|
|
2436
|
+
restoreCalls += 1;
|
|
2437
|
+
if (restoreCalls === 1) {
|
|
2438
|
+
restoreStarted?.();
|
|
2439
|
+
await restoreBlocked;
|
|
2440
|
+
}
|
|
2441
|
+
value = next === null ? null : String(next);
|
|
2442
|
+
},
|
|
2443
|
+
});
|
|
2444
|
+
releaseSetter?.();
|
|
2445
|
+
await restoreStartedPromise;
|
|
2446
|
+
value = null;
|
|
2447
|
+
registry.recordUserEdit?.(identity);
|
|
2448
|
+
releaseRestore?.();
|
|
2449
|
+
expect(await applying).toMatchObject({
|
|
2450
|
+
ok: false,
|
|
2451
|
+
reason: 'staged_value_stale',
|
|
2452
|
+
});
|
|
2453
|
+
expect(value).toBeNull();
|
|
2454
|
+
expect(restoreCalls).toBe(2);
|
|
2455
|
+
});
|
|
2456
|
+
it('uses form-recorded user edits during async policy waits', async () => {
|
|
2457
|
+
let value = 'Ada';
|
|
2458
|
+
let releasePolicy;
|
|
2459
|
+
let policyStarted;
|
|
2460
|
+
const policyBlocked = new Promise((resolve) => {
|
|
2461
|
+
releasePolicy = resolve;
|
|
2462
|
+
});
|
|
2463
|
+
const policyStartedPromise = new Promise((resolve) => {
|
|
2464
|
+
policyStarted = resolve;
|
|
2465
|
+
});
|
|
2466
|
+
const clear = vi.fn(() => {
|
|
2467
|
+
value = '';
|
|
2468
|
+
return true;
|
|
2469
|
+
});
|
|
2470
|
+
const registry = createControlInteractionRegistry({
|
|
2471
|
+
isLocalGesture: () => true,
|
|
2472
|
+
policy: async (command) => {
|
|
2473
|
+
if (command.action === 'clear') {
|
|
2474
|
+
policyStarted?.();
|
|
2475
|
+
await policyBlocked;
|
|
2476
|
+
}
|
|
2477
|
+
return { allowed: true };
|
|
2478
|
+
},
|
|
2479
|
+
});
|
|
2480
|
+
registry.register({
|
|
2481
|
+
identity,
|
|
2482
|
+
metadata: { kind: 'text' },
|
|
2483
|
+
getValue: () => value,
|
|
2484
|
+
setValue: (next) => {
|
|
2485
|
+
value = String(next);
|
|
2486
|
+
},
|
|
2487
|
+
clear,
|
|
2488
|
+
});
|
|
2489
|
+
const clearing = executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent());
|
|
2490
|
+
await policyStartedPromise;
|
|
2491
|
+
value = 'Katherine';
|
|
2492
|
+
registry.recordUserEdit?.(identity);
|
|
2493
|
+
releasePolicy?.();
|
|
2494
|
+
expect(await clearing).toMatchObject({
|
|
2495
|
+
ok: false,
|
|
2496
|
+
reason: 'staged_value_stale',
|
|
2497
|
+
});
|
|
2498
|
+
expect(clear).not.toHaveBeenCalled();
|
|
2499
|
+
expect(value).toBe('Katherine');
|
|
2500
|
+
});
|
|
2501
|
+
it('does not commit successful async clear or undo over newer human edits', async () => {
|
|
2502
|
+
let value = 'Ada';
|
|
2503
|
+
let userEditValue = value;
|
|
2504
|
+
let userEditRevision = 0;
|
|
2505
|
+
let releaseMutation;
|
|
2506
|
+
let mutationStarted;
|
|
2507
|
+
let blockSetter = false;
|
|
2508
|
+
const registry = createControlInteractionRegistry({
|
|
2509
|
+
isLocalGesture: () => true,
|
|
2510
|
+
});
|
|
2511
|
+
const waitForMutation = () => {
|
|
2512
|
+
const blocked = new Promise((resolve) => {
|
|
2513
|
+
releaseMutation = resolve;
|
|
2514
|
+
});
|
|
2515
|
+
const started = new Promise((resolve) => {
|
|
2516
|
+
mutationStarted = resolve;
|
|
2517
|
+
});
|
|
2518
|
+
return { blocked, started };
|
|
2519
|
+
};
|
|
2520
|
+
let pending = waitForMutation();
|
|
2521
|
+
registry.register({
|
|
2522
|
+
identity,
|
|
2523
|
+
metadata: { kind: 'text' },
|
|
2524
|
+
getValue: () => value,
|
|
2525
|
+
getUserEditSnapshot: () => ({
|
|
2526
|
+
revision: userEditRevision,
|
|
2527
|
+
value: userEditValue,
|
|
2528
|
+
}),
|
|
2529
|
+
setValue: async (next) => {
|
|
2530
|
+
if (blockSetter) {
|
|
2531
|
+
mutationStarted?.();
|
|
2532
|
+
await pending.blocked;
|
|
2533
|
+
}
|
|
2534
|
+
value = String(next);
|
|
2535
|
+
},
|
|
2536
|
+
restoreValue: (next) => {
|
|
2537
|
+
value = String(next);
|
|
2538
|
+
},
|
|
2539
|
+
clear: async () => {
|
|
2540
|
+
mutationStarted?.();
|
|
2541
|
+
await pending.blocked;
|
|
2542
|
+
value = '';
|
|
2543
|
+
return true;
|
|
2544
|
+
},
|
|
2545
|
+
});
|
|
2546
|
+
const clearing = executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent());
|
|
2547
|
+
await pending.started;
|
|
2548
|
+
value = 'Katherine';
|
|
2549
|
+
userEditValue = value;
|
|
2550
|
+
userEditRevision += 1;
|
|
2551
|
+
releaseMutation?.();
|
|
2552
|
+
expect(await clearing).toMatchObject({
|
|
2553
|
+
ok: false,
|
|
2554
|
+
reason: 'staged_value_stale',
|
|
2555
|
+
});
|
|
2556
|
+
expect(value).toBe('Katherine');
|
|
2557
|
+
blockSetter = false;
|
|
2558
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2559
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent())).toMatchObject({ ok: true });
|
|
2560
|
+
pending = waitForMutation();
|
|
2561
|
+
blockSetter = true;
|
|
2562
|
+
const undoing = executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent());
|
|
2563
|
+
await pending.started;
|
|
2564
|
+
value = 'Byron';
|
|
2565
|
+
userEditValue = value;
|
|
2566
|
+
userEditRevision += 1;
|
|
2567
|
+
releaseMutation?.();
|
|
2568
|
+
expect(await undoing).toMatchObject({
|
|
2569
|
+
ok: false,
|
|
2570
|
+
reason: 'staged_value_stale',
|
|
2571
|
+
});
|
|
2572
|
+
expect(value).toBe('Byron');
|
|
2573
|
+
});
|
|
2574
|
+
it('does not collide subject identities containing separators', async () => {
|
|
2575
|
+
const firstIdentity = {
|
|
2576
|
+
...identity,
|
|
2577
|
+
subject: { type: 'record:x', id: '1' },
|
|
2578
|
+
};
|
|
2579
|
+
const secondIdentity = {
|
|
2580
|
+
...identity,
|
|
2581
|
+
subject: { type: 'record', id: 'x:1' },
|
|
2582
|
+
};
|
|
2583
|
+
const registry = createControlInteractionRegistry();
|
|
2584
|
+
registry.register({
|
|
2585
|
+
identity: firstIdentity,
|
|
2586
|
+
metadata: { kind: 'text' },
|
|
2587
|
+
getValue: () => 'First',
|
|
2588
|
+
setValue: () => undefined,
|
|
2589
|
+
});
|
|
2590
|
+
registry.register({
|
|
2591
|
+
identity: secondIdentity,
|
|
2592
|
+
metadata: { kind: 'text' },
|
|
2593
|
+
getValue: () => 'Second',
|
|
2594
|
+
setValue: () => undefined,
|
|
2595
|
+
});
|
|
2596
|
+
await registry.execute({ action: 'stage', identity: firstIdentity, value: 'Grace' }, { source: 'agent' });
|
|
2597
|
+
expect(registry.list()).toHaveLength(2);
|
|
2598
|
+
expect(registry.get(firstIdentity)?.state.staged?.value).toBe('Grace');
|
|
2599
|
+
expect(registry.get(secondIdentity)?.state.staged).toBeUndefined();
|
|
2600
|
+
});
|
|
2601
|
+
it('runs focus, reveal, and highlight without coupling to a DOM implementation', async () => {
|
|
2602
|
+
const focus = vi.fn();
|
|
2603
|
+
const reveal = vi.fn();
|
|
2604
|
+
const highlight = vi.fn();
|
|
2605
|
+
const registry = createControlInteractionRegistry();
|
|
2606
|
+
registry.register({
|
|
2607
|
+
identity,
|
|
2608
|
+
metadata: { kind: 'text' },
|
|
2609
|
+
focus,
|
|
2610
|
+
reveal,
|
|
2611
|
+
highlight,
|
|
87
2612
|
});
|
|
88
2613
|
await registry.execute({ action: 'focus', identity });
|
|
89
2614
|
await registry.execute({ action: 'reveal', identity });
|
|
@@ -92,4 +2617,787 @@ describe('control interaction registry', () => {
|
|
|
92
2617
|
expect(reveal).toHaveBeenCalledOnce();
|
|
93
2618
|
expect(highlight).toHaveBeenCalledWith(400);
|
|
94
2619
|
});
|
|
2620
|
+
it('rejects a recognized factory event retained beyond its dispatch', async () => {
|
|
2621
|
+
let captured;
|
|
2622
|
+
let value = 'Ada';
|
|
2623
|
+
const target = new EventTarget();
|
|
2624
|
+
const registry = createControlInteractionRegistry({
|
|
2625
|
+
isLocalGesture: () => true,
|
|
2626
|
+
});
|
|
2627
|
+
registry.register({
|
|
2628
|
+
identity,
|
|
2629
|
+
metadata: { kind: 'text' },
|
|
2630
|
+
getValue: () => value,
|
|
2631
|
+
setValue: (next) => {
|
|
2632
|
+
value = String(next);
|
|
2633
|
+
},
|
|
2634
|
+
});
|
|
2635
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2636
|
+
target.addEventListener('click', (event) => {
|
|
2637
|
+
captured = event;
|
|
2638
|
+
});
|
|
2639
|
+
target.dispatchEvent(new Event('click'));
|
|
2640
|
+
if (!captured)
|
|
2641
|
+
throw new Error('event was not captured');
|
|
2642
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, captured)).toMatchObject({ ok: false, reason: 'local_gesture_required' });
|
|
2643
|
+
expect(value).toBe('Ada');
|
|
2644
|
+
});
|
|
2645
|
+
it('rejects forged and own-property-shadowed events in the factory path', async () => {
|
|
2646
|
+
const execute = vi.fn();
|
|
2647
|
+
const registry = createControlInteractionRegistry({
|
|
2648
|
+
isLocalGesture: () => true,
|
|
2649
|
+
});
|
|
2650
|
+
registry.register({
|
|
2651
|
+
identity,
|
|
2652
|
+
metadata: { kind: 'text' },
|
|
2653
|
+
getValue: () => 'Ada',
|
|
2654
|
+
setValue: execute,
|
|
2655
|
+
});
|
|
2656
|
+
const forged = { isTrusted: true, eventPhase: Event.AT_TARGET };
|
|
2657
|
+
const shadowed = new Event('click');
|
|
2658
|
+
Object.defineProperty(shadowed, 'eventPhase', {
|
|
2659
|
+
value: Event.AT_TARGET,
|
|
2660
|
+
});
|
|
2661
|
+
await expect(executeLocalControlCommand(registry, { action: 'clear', identity }, forged)).resolves.toMatchObject({
|
|
2662
|
+
ok: false,
|
|
2663
|
+
reason: 'local_gesture_required',
|
|
2664
|
+
});
|
|
2665
|
+
await expect(executeLocalControlCommand(registry, { action: 'clear', identity }, shadowed)).resolves.toMatchObject({
|
|
2666
|
+
ok: false,
|
|
2667
|
+
reason: 'local_gesture_required',
|
|
2668
|
+
});
|
|
2669
|
+
expect(execute).not.toHaveBeenCalled();
|
|
2670
|
+
});
|
|
2671
|
+
it('dispatches a copied gesture executor through the supplied registry wrapper', async () => {
|
|
2672
|
+
let value = 'Ada';
|
|
2673
|
+
const baseRegistry = createControlInteractionRegistry({
|
|
2674
|
+
isLocalGesture: () => true,
|
|
2675
|
+
});
|
|
2676
|
+
baseRegistry.register({
|
|
2677
|
+
identity,
|
|
2678
|
+
metadata: { kind: 'text' },
|
|
2679
|
+
getValue: () => value,
|
|
2680
|
+
setValue: (next) => {
|
|
2681
|
+
value = String(next);
|
|
2682
|
+
},
|
|
2683
|
+
});
|
|
2684
|
+
await baseRegistry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2685
|
+
const wrapperExecute = vi.fn(async (command) => ({
|
|
2686
|
+
ok: false,
|
|
2687
|
+
action: command.action,
|
|
2688
|
+
identity: command.identity,
|
|
2689
|
+
reason: 'wrapper_denied',
|
|
2690
|
+
}));
|
|
2691
|
+
const wrapperRegistry = {
|
|
2692
|
+
...baseRegistry,
|
|
2693
|
+
execute: wrapperExecute,
|
|
2694
|
+
};
|
|
2695
|
+
let denied;
|
|
2696
|
+
let replayed;
|
|
2697
|
+
const target = new EventTarget();
|
|
2698
|
+
target.addEventListener('click', (event) => {
|
|
2699
|
+
denied = executeLocalControlCommand(wrapperRegistry, { action: 'apply', identity, revision: 1 }, event);
|
|
2700
|
+
replayed = executeLocalControlCommand(wrapperRegistry, { action: 'apply', identity, revision: 1 }, event);
|
|
2701
|
+
}, { once: true });
|
|
2702
|
+
target.dispatchEvent(new Event('click'));
|
|
2703
|
+
expect(await denied).toMatchObject({
|
|
2704
|
+
ok: false,
|
|
2705
|
+
reason: 'wrapper_denied',
|
|
2706
|
+
});
|
|
2707
|
+
expect(await replayed).toMatchObject({
|
|
2708
|
+
ok: false,
|
|
2709
|
+
reason: 'local_gesture_required',
|
|
2710
|
+
});
|
|
2711
|
+
expect(wrapperExecute).toHaveBeenCalledOnce();
|
|
2712
|
+
expect(value).toBe('Ada');
|
|
2713
|
+
});
|
|
2714
|
+
it('revokes a wrapper-retained local context after exact delegation', async () => {
|
|
2715
|
+
let value = 'Ada';
|
|
2716
|
+
let retainedContext;
|
|
2717
|
+
const baseRegistry = createControlInteractionRegistry({
|
|
2718
|
+
isLocalGesture: () => true,
|
|
2719
|
+
});
|
|
2720
|
+
baseRegistry.register({
|
|
2721
|
+
identity,
|
|
2722
|
+
metadata: { kind: 'text' },
|
|
2723
|
+
getValue: () => value,
|
|
2724
|
+
setValue: (next) => {
|
|
2725
|
+
value = String(next);
|
|
2726
|
+
},
|
|
2727
|
+
});
|
|
2728
|
+
await baseRegistry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2729
|
+
const wrapperRegistry = {
|
|
2730
|
+
...baseRegistry,
|
|
2731
|
+
execute: async (command, context = { source: 'user', confirmed: true }) => {
|
|
2732
|
+
retainedContext = context;
|
|
2733
|
+
return baseRegistry.execute(command, context);
|
|
2734
|
+
},
|
|
2735
|
+
};
|
|
2736
|
+
expect(await dispatchLocalGesture((event) => executeLocalControlCommand(wrapperRegistry, { action: 'apply', identity, revision: 1 }, event))).toMatchObject({ ok: true });
|
|
2737
|
+
if (!retainedContext)
|
|
2738
|
+
throw new Error('wrapper did not retain context');
|
|
2739
|
+
expect(await baseRegistry.execute({ action: 'clear', identity }, retainedContext)).toMatchObject({ ok: false, reason: 'local_gesture_required' });
|
|
2740
|
+
expect(value).toBe('Grace');
|
|
2741
|
+
});
|
|
2742
|
+
it('rejects wrapper substitution for a locally authorized command', async () => {
|
|
2743
|
+
let value = 'Ada';
|
|
2744
|
+
const baseRegistry = createControlInteractionRegistry({
|
|
2745
|
+
isLocalGesture: () => true,
|
|
2746
|
+
});
|
|
2747
|
+
baseRegistry.register({
|
|
2748
|
+
identity,
|
|
2749
|
+
metadata: { kind: 'text' },
|
|
2750
|
+
getValue: () => value,
|
|
2751
|
+
setValue: (next) => {
|
|
2752
|
+
value = String(next);
|
|
2753
|
+
},
|
|
2754
|
+
});
|
|
2755
|
+
await baseRegistry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2756
|
+
const wrapperRegistry = {
|
|
2757
|
+
...baseRegistry,
|
|
2758
|
+
execute: (_command, context = { source: 'user', confirmed: true }) => baseRegistry.execute({ action: 'apply', identity, revision: 1 }, context),
|
|
2759
|
+
};
|
|
2760
|
+
expect(await dispatchLocalGesture((event) => executeLocalControlCommand(wrapperRegistry, { action: 'discard', identity, revision: 1 }, event))).toMatchObject({
|
|
2761
|
+
ok: false,
|
|
2762
|
+
action: 'apply',
|
|
2763
|
+
reason: 'local_gesture_required',
|
|
2764
|
+
});
|
|
2765
|
+
expect(value).toBe('Ada');
|
|
2766
|
+
expect(baseRegistry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
2767
|
+
});
|
|
2768
|
+
it('allows only one base delegation from a wrapper invocation', async () => {
|
|
2769
|
+
let value = 'Ada';
|
|
2770
|
+
let secondDelegation = null;
|
|
2771
|
+
const baseRegistry = createControlInteractionRegistry({
|
|
2772
|
+
isLocalGesture: () => true,
|
|
2773
|
+
});
|
|
2774
|
+
baseRegistry.register({
|
|
2775
|
+
identity,
|
|
2776
|
+
metadata: { kind: 'text' },
|
|
2777
|
+
getValue: () => value,
|
|
2778
|
+
setValue: (next) => {
|
|
2779
|
+
value = String(next);
|
|
2780
|
+
},
|
|
2781
|
+
});
|
|
2782
|
+
await baseRegistry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2783
|
+
const wrapperRegistry = {
|
|
2784
|
+
...baseRegistry,
|
|
2785
|
+
execute: async (command, context = { source: 'user', confirmed: true }) => {
|
|
2786
|
+
const first = await baseRegistry.execute(command, context);
|
|
2787
|
+
secondDelegation = await baseRegistry.execute(command, context);
|
|
2788
|
+
return first;
|
|
2789
|
+
},
|
|
2790
|
+
};
|
|
2791
|
+
expect(await dispatchLocalGesture((event) => executeLocalControlCommand(wrapperRegistry, { action: 'apply', identity, revision: 1 }, event))).toMatchObject({ ok: true });
|
|
2792
|
+
expect(secondDelegation).toMatchObject({
|
|
2793
|
+
ok: false,
|
|
2794
|
+
reason: 'local_gesture_required',
|
|
2795
|
+
});
|
|
2796
|
+
expect(value).toBe('Grace');
|
|
2797
|
+
});
|
|
2798
|
+
it('binds every local batch command to its gesture-time registration', async () => {
|
|
2799
|
+
const triggerIdentity = { ...identity, controlId: 'trigger' };
|
|
2800
|
+
const targetIdentity = { ...identity, controlId: 'target' };
|
|
2801
|
+
let triggerValue = 'armed';
|
|
2802
|
+
let originalValue = 'Ada';
|
|
2803
|
+
let replacementValue = 'Ada';
|
|
2804
|
+
const registry = createControlInteractionRegistry({
|
|
2805
|
+
isLocalGesture: () => true,
|
|
2806
|
+
});
|
|
2807
|
+
registry.register({
|
|
2808
|
+
identity: targetIdentity,
|
|
2809
|
+
metadata: { kind: 'text' },
|
|
2810
|
+
getValue: () => originalValue,
|
|
2811
|
+
setValue: (next) => {
|
|
2812
|
+
originalValue = String(next);
|
|
2813
|
+
},
|
|
2814
|
+
});
|
|
2815
|
+
registry.register({
|
|
2816
|
+
identity: triggerIdentity,
|
|
2817
|
+
metadata: { kind: 'text' },
|
|
2818
|
+
getValue: () => triggerValue,
|
|
2819
|
+
setValue: (next) => {
|
|
2820
|
+
triggerValue = String(next);
|
|
2821
|
+
},
|
|
2822
|
+
clear: () => {
|
|
2823
|
+
triggerValue = '';
|
|
2824
|
+
registry.register({
|
|
2825
|
+
identity: targetIdentity,
|
|
2826
|
+
metadata: { kind: 'text' },
|
|
2827
|
+
getValue: () => replacementValue,
|
|
2828
|
+
setValue: (next) => {
|
|
2829
|
+
replacementValue = String(next);
|
|
2830
|
+
},
|
|
2831
|
+
});
|
|
2832
|
+
return true;
|
|
2833
|
+
},
|
|
2834
|
+
});
|
|
2835
|
+
await registry.execute({ action: 'stage', identity: targetIdentity, value: 'Grace' }, { source: 'agent' });
|
|
2836
|
+
const batch = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
|
|
2837
|
+
{ action: 'clear', identity: triggerIdentity },
|
|
2838
|
+
{ action: 'apply', identity: targetIdentity, revision: 1 },
|
|
2839
|
+
], event));
|
|
2840
|
+
expect(batch).toMatchObject({
|
|
2841
|
+
ok: false,
|
|
2842
|
+
results: [
|
|
2843
|
+
{ ok: true, action: 'clear' },
|
|
2844
|
+
{
|
|
2845
|
+
ok: false,
|
|
2846
|
+
action: 'apply',
|
|
2847
|
+
reason: 'local_gesture_required',
|
|
2848
|
+
},
|
|
2849
|
+
],
|
|
2850
|
+
});
|
|
2851
|
+
expect(triggerValue).toBe('');
|
|
2852
|
+
expect(originalValue).toBe('Ada');
|
|
2853
|
+
expect(replacementValue).toBe('Ada');
|
|
2854
|
+
expect(registry.get(targetIdentity)?.state.value).toBe('Ada');
|
|
2855
|
+
});
|
|
2856
|
+
it('rejects a trusted legacy-registry event retained beyond its dispatch', async () => {
|
|
2857
|
+
let captured;
|
|
2858
|
+
const execute = vi.fn(async (command) => ({
|
|
2859
|
+
ok: true,
|
|
2860
|
+
action: command.action,
|
|
2861
|
+
identity: command.identity,
|
|
2862
|
+
}));
|
|
2863
|
+
const legacyRegistry = { execute };
|
|
2864
|
+
const target = new EventTarget();
|
|
2865
|
+
const event = new Event('click');
|
|
2866
|
+
target.addEventListener('click', (candidate) => {
|
|
2867
|
+
captured = candidate;
|
|
2868
|
+
});
|
|
2869
|
+
target.dispatchEvent(event);
|
|
2870
|
+
if (!captured)
|
|
2871
|
+
throw new Error('event was not captured');
|
|
2872
|
+
const retainedTrustedEvent = {
|
|
2873
|
+
isTrusted: true,
|
|
2874
|
+
eventPhase: captured.eventPhase,
|
|
2875
|
+
};
|
|
2876
|
+
expect(await executeLocalControlCommand(legacyRegistry, { action: 'clear', identity }, retainedTrustedEvent)).toMatchObject({ ok: false, reason: 'local_gesture_required' });
|
|
2877
|
+
expect(execute).not.toHaveBeenCalled();
|
|
2878
|
+
});
|
|
2879
|
+
it('rejects forged and own-property-shadowed events in the legacy path', async () => {
|
|
2880
|
+
const execute = vi.fn(async (command) => ({
|
|
2881
|
+
ok: true,
|
|
2882
|
+
action: command.action,
|
|
2883
|
+
identity: command.identity,
|
|
2884
|
+
}));
|
|
2885
|
+
const legacyRegistry = { execute };
|
|
2886
|
+
const forged = { isTrusted: true, eventPhase: Event.AT_TARGET };
|
|
2887
|
+
const shadowed = new Event('click');
|
|
2888
|
+
Object.defineProperty(shadowed, 'eventPhase', {
|
|
2889
|
+
value: Event.AT_TARGET,
|
|
2890
|
+
});
|
|
2891
|
+
for (const event of [forged, shadowed]) {
|
|
2892
|
+
await expect(executeLocalControlCommand(legacyRegistry, { action: 'clear', identity }, event)).resolves.toMatchObject({
|
|
2893
|
+
ok: false,
|
|
2894
|
+
reason: 'local_gesture_required',
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
expect(execute).not.toHaveBeenCalled();
|
|
2898
|
+
});
|
|
2899
|
+
it('keeps an older disposer from removing a reused registration generation', () => {
|
|
2900
|
+
const registry = createControlInteractionRegistry();
|
|
2901
|
+
const registration = {
|
|
2902
|
+
identity,
|
|
2903
|
+
metadata: { kind: 'text' },
|
|
2904
|
+
getValue: () => 'Ada',
|
|
2905
|
+
};
|
|
2906
|
+
const disposeFirst = registry.register(registration);
|
|
2907
|
+
registry.register(registration);
|
|
2908
|
+
disposeFirst();
|
|
2909
|
+
expect(registry.get(identity)?.state.value).toBe('Ada');
|
|
2910
|
+
});
|
|
2911
|
+
it('binds a local batch grant to the first use of a reused registration object', async () => {
|
|
2912
|
+
const triggerIdentity = { ...identity, controlId: 'trigger' };
|
|
2913
|
+
let targetValue = 'Ada';
|
|
2914
|
+
const registry = createControlInteractionRegistry({
|
|
2915
|
+
isLocalGesture: () => true,
|
|
2916
|
+
});
|
|
2917
|
+
const targetRegistration = {
|
|
2918
|
+
identity,
|
|
2919
|
+
metadata: { kind: 'text' },
|
|
2920
|
+
getValue: () => targetValue,
|
|
2921
|
+
setValue: (next) => {
|
|
2922
|
+
targetValue = String(next);
|
|
2923
|
+
},
|
|
2924
|
+
};
|
|
2925
|
+
registry.register(targetRegistration);
|
|
2926
|
+
registry.register({
|
|
2927
|
+
identity: triggerIdentity,
|
|
2928
|
+
metadata: { kind: 'text' },
|
|
2929
|
+
getValue: () => 'armed',
|
|
2930
|
+
clear: () => {
|
|
2931
|
+
registry.register(targetRegistration);
|
|
2932
|
+
return true;
|
|
2933
|
+
},
|
|
2934
|
+
});
|
|
2935
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2936
|
+
const batch = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
|
|
2937
|
+
{ action: 'clear', identity: triggerIdentity },
|
|
2938
|
+
{ action: 'apply', identity, revision: 1 },
|
|
2939
|
+
], event));
|
|
2940
|
+
expect(batch.results).toMatchObject([
|
|
2941
|
+
{ ok: true, action: 'clear' },
|
|
2942
|
+
{ ok: false, action: 'apply', reason: 'local_gesture_required' },
|
|
2943
|
+
]);
|
|
2944
|
+
expect(targetValue).toBe('Ada');
|
|
2945
|
+
});
|
|
2946
|
+
it('rejects an in-flight mutation that reuses the same registration object', async () => {
|
|
2947
|
+
let value = 'Ada';
|
|
2948
|
+
const registry = createControlInteractionRegistry({
|
|
2949
|
+
isLocalGesture: () => true,
|
|
2950
|
+
});
|
|
2951
|
+
const registration = {
|
|
2952
|
+
identity,
|
|
2953
|
+
metadata: { kind: 'text' },
|
|
2954
|
+
getValue: () => value,
|
|
2955
|
+
setValue: (next) => {
|
|
2956
|
+
value = String(next);
|
|
2957
|
+
registry.register(registration);
|
|
2958
|
+
},
|
|
2959
|
+
};
|
|
2960
|
+
registry.register(registration);
|
|
2961
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
2962
|
+
const result = await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, event));
|
|
2963
|
+
expect(result).toMatchObject({
|
|
2964
|
+
ok: false,
|
|
2965
|
+
reason: 'staged_value_stale',
|
|
2966
|
+
});
|
|
2967
|
+
expect(value).toBe('Ada');
|
|
2968
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
2969
|
+
});
|
|
2970
|
+
it('binds a local gesture to an immutable command snapshot', async () => {
|
|
2971
|
+
let value = 'Ada';
|
|
2972
|
+
const otherIdentity = { ...identity, controlId: 'other' };
|
|
2973
|
+
const registry = createControlInteractionRegistry({
|
|
2974
|
+
isLocalGesture: () => true,
|
|
2975
|
+
});
|
|
2976
|
+
registry.register({
|
|
2977
|
+
identity,
|
|
2978
|
+
metadata: { kind: 'custom' },
|
|
2979
|
+
getValue: () => value,
|
|
2980
|
+
setValue: (next) => {
|
|
2981
|
+
value = next;
|
|
2982
|
+
},
|
|
2983
|
+
});
|
|
2984
|
+
const command = {
|
|
2985
|
+
action: 'apply',
|
|
2986
|
+
identity: { ...identity },
|
|
2987
|
+
value: { profile: { name: 'Grace' } },
|
|
2988
|
+
};
|
|
2989
|
+
const pending = dispatchLocalGesture((event) => executeLocalControlCommand(registry, command, event));
|
|
2990
|
+
command.action = 'clear';
|
|
2991
|
+
command.identity = otherIdentity;
|
|
2992
|
+
command.value = { profile: { name: 'Forged' } };
|
|
2993
|
+
expect(await pending).toMatchObject({
|
|
2994
|
+
ok: true,
|
|
2995
|
+
action: 'apply',
|
|
2996
|
+
identity,
|
|
2997
|
+
});
|
|
2998
|
+
expect(value).toEqual({ profile: { name: 'Grace' } });
|
|
2999
|
+
});
|
|
3000
|
+
it('snapshots a canonical-review marker with its batch value before awaiting', async () => {
|
|
3001
|
+
let value = 'Existing';
|
|
3002
|
+
let blockValidation = false;
|
|
3003
|
+
let validationStarted;
|
|
3004
|
+
let releaseValidation;
|
|
3005
|
+
const started = new Promise((resolve) => {
|
|
3006
|
+
validationStarted = resolve;
|
|
3007
|
+
});
|
|
3008
|
+
const blocked = new Promise((resolve) => {
|
|
3009
|
+
releaseValidation = resolve;
|
|
3010
|
+
});
|
|
3011
|
+
const prepareValue = vi.fn((proposal) => `${value}\n${String(proposal)}`);
|
|
3012
|
+
const registry = createControlInteractionRegistry({
|
|
3013
|
+
isLocalGesture: () => true,
|
|
3014
|
+
});
|
|
3015
|
+
registry.register({
|
|
3016
|
+
identity,
|
|
3017
|
+
metadata: { kind: 'textarea' },
|
|
3018
|
+
getValue: () => value,
|
|
3019
|
+
prepareValue,
|
|
3020
|
+
prepareReviewedValue: (reviewed) => String(reviewed),
|
|
3021
|
+
setValue: (next) => {
|
|
3022
|
+
value = String(next);
|
|
3023
|
+
},
|
|
3024
|
+
validateValue: async () => {
|
|
3025
|
+
if (blockValidation) {
|
|
3026
|
+
validationStarted?.();
|
|
3027
|
+
await blocked;
|
|
3028
|
+
}
|
|
3029
|
+
return true;
|
|
3030
|
+
},
|
|
3031
|
+
});
|
|
3032
|
+
await registry.execute({ action: 'stage', identity, value: 'Proposed' }, { source: 'agent' });
|
|
3033
|
+
blockValidation = true;
|
|
3034
|
+
const command = {
|
|
3035
|
+
action: 'apply',
|
|
3036
|
+
identity,
|
|
3037
|
+
revision: 1,
|
|
3038
|
+
value: 'Existing\nReviewed edit',
|
|
3039
|
+
reviewedValueIsCanonical: true,
|
|
3040
|
+
};
|
|
3041
|
+
const pending = dispatchLocalGesture((event) => executeLocalControlBatch(registry, [command], event));
|
|
3042
|
+
await started;
|
|
3043
|
+
command.value = 'Forged raw proposal';
|
|
3044
|
+
delete command.reviewedValueIsCanonical;
|
|
3045
|
+
releaseValidation?.();
|
|
3046
|
+
expect(await pending).toMatchObject({ ok: true });
|
|
3047
|
+
expect(value).toBe('Existing\nReviewed edit');
|
|
3048
|
+
expect(prepareValue).toHaveBeenCalledTimes(1);
|
|
3049
|
+
});
|
|
3050
|
+
it('snapshots every public batch command before awaiting the first', async () => {
|
|
3051
|
+
let releaseFirst;
|
|
3052
|
+
let firstStarted;
|
|
3053
|
+
const blocked = new Promise((resolve) => {
|
|
3054
|
+
releaseFirst = resolve;
|
|
3055
|
+
});
|
|
3056
|
+
const started = new Promise((resolve) => {
|
|
3057
|
+
firstStarted = resolve;
|
|
3058
|
+
});
|
|
3059
|
+
const registry = createControlInteractionRegistry();
|
|
3060
|
+
registry.register({
|
|
3061
|
+
identity,
|
|
3062
|
+
metadata: { kind: 'custom' },
|
|
3063
|
+
getValue: () => 'Ada',
|
|
3064
|
+
setValue: () => undefined,
|
|
3065
|
+
validateValue: async (candidate) => {
|
|
3066
|
+
if (candidate.order === 1) {
|
|
3067
|
+
firstStarted?.();
|
|
3068
|
+
await blocked;
|
|
3069
|
+
}
|
|
3070
|
+
return true;
|
|
3071
|
+
},
|
|
3072
|
+
});
|
|
3073
|
+
const commands = [
|
|
3074
|
+
{ action: 'stage', identity, value: { order: 1 } },
|
|
3075
|
+
{ action: 'stage', identity, value: { order: 2 } },
|
|
3076
|
+
];
|
|
3077
|
+
const pending = registry.executeBatch?.(commands, { source: 'agent' });
|
|
3078
|
+
await started;
|
|
3079
|
+
commands[1].value.order = 99;
|
|
3080
|
+
releaseFirst?.();
|
|
3081
|
+
expect((await pending)?.ok).toBe(true);
|
|
3082
|
+
expect(registry.get(identity)?.state.staged?.value).toEqual({ order: 2 });
|
|
3083
|
+
});
|
|
3084
|
+
it('snapshots batch context before awaiting the first command', async () => {
|
|
3085
|
+
let releaseFirst;
|
|
3086
|
+
let firstStarted;
|
|
3087
|
+
const blocked = new Promise((resolve) => {
|
|
3088
|
+
releaseFirst = resolve;
|
|
3089
|
+
});
|
|
3090
|
+
const started = new Promise((resolve) => {
|
|
3091
|
+
firstStarted = resolve;
|
|
3092
|
+
});
|
|
3093
|
+
const policyContexts = [];
|
|
3094
|
+
const registry = createControlInteractionRegistry({
|
|
3095
|
+
policy: (_command, context) => {
|
|
3096
|
+
policyContexts.push({
|
|
3097
|
+
source: context.source,
|
|
3098
|
+
actorId: context.actorId,
|
|
3099
|
+
sessionId: context.sessionId,
|
|
3100
|
+
});
|
|
3101
|
+
return { allowed: true };
|
|
3102
|
+
},
|
|
3103
|
+
});
|
|
3104
|
+
const firstIdentity = { ...identity, controlId: 'first' };
|
|
3105
|
+
const secondIdentity = { ...identity, controlId: 'second' };
|
|
3106
|
+
registry.register({
|
|
3107
|
+
identity: firstIdentity,
|
|
3108
|
+
metadata: { kind: 'text' },
|
|
3109
|
+
getValue: () => '',
|
|
3110
|
+
setValue: () => undefined,
|
|
3111
|
+
validateValue: async () => {
|
|
3112
|
+
firstStarted?.();
|
|
3113
|
+
await blocked;
|
|
3114
|
+
return true;
|
|
3115
|
+
},
|
|
3116
|
+
});
|
|
3117
|
+
registry.register({
|
|
3118
|
+
identity: secondIdentity,
|
|
3119
|
+
metadata: { kind: 'text' },
|
|
3120
|
+
getValue: () => '',
|
|
3121
|
+
setValue: () => undefined,
|
|
3122
|
+
validateValue: () => true,
|
|
3123
|
+
});
|
|
3124
|
+
const context = {
|
|
3125
|
+
source: 'agent',
|
|
3126
|
+
actorId: 'agent-original',
|
|
3127
|
+
sessionId: 'session-original',
|
|
3128
|
+
};
|
|
3129
|
+
const pending = registry.executeBatch?.([
|
|
3130
|
+
{ action: 'stage', identity: firstIdentity, value: 'A' },
|
|
3131
|
+
{ action: 'stage', identity: secondIdentity, value: 'B' },
|
|
3132
|
+
], context);
|
|
3133
|
+
await started;
|
|
3134
|
+
Object.assign(context, {
|
|
3135
|
+
source: 'tutorial',
|
|
3136
|
+
actorId: 'agent-mutated',
|
|
3137
|
+
sessionId: 'session-mutated',
|
|
3138
|
+
});
|
|
3139
|
+
releaseFirst?.();
|
|
3140
|
+
expect((await pending)?.ok).toBe(true);
|
|
3141
|
+
const expectedProvenance = {
|
|
3142
|
+
source: 'agent',
|
|
3143
|
+
actorId: 'agent-original',
|
|
3144
|
+
sessionId: 'session-original',
|
|
3145
|
+
};
|
|
3146
|
+
expect(registry.get(firstIdentity)?.state.staged?.provenance).toEqual(expectedProvenance);
|
|
3147
|
+
expect(registry.get(secondIdentity)?.state.staged?.provenance).toEqual(expectedProvenance);
|
|
3148
|
+
expect(policyContexts).toEqual([expectedProvenance, expectedProvenance]);
|
|
3149
|
+
});
|
|
3150
|
+
it('snapshots a public execute command before its queued mutation runs', async () => {
|
|
3151
|
+
const registry = createControlInteractionRegistry();
|
|
3152
|
+
registry.register({
|
|
3153
|
+
identity,
|
|
3154
|
+
metadata: { kind: 'custom' },
|
|
3155
|
+
getValue: () => 'Ada',
|
|
3156
|
+
setValue: () => undefined,
|
|
3157
|
+
});
|
|
3158
|
+
const command = {
|
|
3159
|
+
action: 'stage',
|
|
3160
|
+
identity: { ...identity },
|
|
3161
|
+
value: { profile: { name: 'Grace' } },
|
|
3162
|
+
};
|
|
3163
|
+
const pending = registry.execute(command, { source: 'agent' });
|
|
3164
|
+
command.identity.controlId = 'forged';
|
|
3165
|
+
command.value.profile.name = 'Forged';
|
|
3166
|
+
expect(await pending).toMatchObject({ ok: true, identity });
|
|
3167
|
+
expect(registry.get(identity)?.state.staged?.value).toEqual({
|
|
3168
|
+
profile: { name: 'Grace' },
|
|
3169
|
+
});
|
|
3170
|
+
});
|
|
3171
|
+
it('returns structured failures when public execution cannot clone commands', async () => {
|
|
3172
|
+
const registry = createControlInteractionRegistry();
|
|
3173
|
+
registry.register({
|
|
3174
|
+
identity,
|
|
3175
|
+
metadata: { kind: 'custom' },
|
|
3176
|
+
getValue: () => 'Ada',
|
|
3177
|
+
setValue: () => undefined,
|
|
3178
|
+
});
|
|
3179
|
+
const command = {
|
|
3180
|
+
action: 'stage',
|
|
3181
|
+
identity,
|
|
3182
|
+
value: () => undefined,
|
|
3183
|
+
};
|
|
3184
|
+
expect(await registry.execute(command, { source: 'agent' })).toMatchObject({
|
|
3185
|
+
ok: false,
|
|
3186
|
+
action: 'stage',
|
|
3187
|
+
identity,
|
|
3188
|
+
reason: 'command_failed',
|
|
3189
|
+
});
|
|
3190
|
+
expect(await registry.executeBatch?.([command], { source: 'agent' })).toMatchObject({
|
|
3191
|
+
ok: false,
|
|
3192
|
+
results: [
|
|
3193
|
+
{
|
|
3194
|
+
ok: false,
|
|
3195
|
+
action: 'stage',
|
|
3196
|
+
identity,
|
|
3197
|
+
reason: 'command_failed',
|
|
3198
|
+
},
|
|
3199
|
+
],
|
|
3200
|
+
});
|
|
3201
|
+
expect(registry.get(identity)?.state.staged).toBeUndefined();
|
|
3202
|
+
});
|
|
3203
|
+
it('keeps clone failures structured when command accessors throw', async () => {
|
|
3204
|
+
const registry = createControlInteractionRegistry();
|
|
3205
|
+
const command = {};
|
|
3206
|
+
Object.defineProperties(command, {
|
|
3207
|
+
action: {
|
|
3208
|
+
enumerable: true,
|
|
3209
|
+
get() {
|
|
3210
|
+
throw new Error('hostile action');
|
|
3211
|
+
},
|
|
3212
|
+
},
|
|
3213
|
+
identity: {
|
|
3214
|
+
enumerable: true,
|
|
3215
|
+
get() {
|
|
3216
|
+
throw new Error('hostile identity');
|
|
3217
|
+
},
|
|
3218
|
+
},
|
|
3219
|
+
});
|
|
3220
|
+
await expect(registry.execute(command, { source: 'agent' })).resolves.toEqual({
|
|
3221
|
+
ok: false,
|
|
3222
|
+
action: 'stage',
|
|
3223
|
+
identity: { formId: '', controlId: '' },
|
|
3224
|
+
reason: 'command_failed',
|
|
3225
|
+
});
|
|
3226
|
+
});
|
|
3227
|
+
it('fails closed on an uncloneable command without consuming the gesture', async () => {
|
|
3228
|
+
let value = 'Ada';
|
|
3229
|
+
const registry = createControlInteractionRegistry({
|
|
3230
|
+
isLocalGesture: () => true,
|
|
3231
|
+
});
|
|
3232
|
+
registry.register({
|
|
3233
|
+
identity,
|
|
3234
|
+
metadata: { kind: 'text' },
|
|
3235
|
+
getValue: () => value,
|
|
3236
|
+
clear: () => {
|
|
3237
|
+
value = '';
|
|
3238
|
+
return true;
|
|
3239
|
+
},
|
|
3240
|
+
});
|
|
3241
|
+
const [failed, cleared] = await dispatchLocalGesture((event) => {
|
|
3242
|
+
const failed = executeLocalControlCommand(registry, { action: 'apply', identity, value: () => undefined }, event);
|
|
3243
|
+
const cleared = executeLocalControlCommand(registry, { action: 'clear', identity }, event);
|
|
3244
|
+
return Promise.all([failed, cleared]);
|
|
3245
|
+
});
|
|
3246
|
+
expect(failed).toMatchObject({ ok: false, reason: 'command_failed' });
|
|
3247
|
+
expect(cleared).toMatchObject({ ok: true });
|
|
3248
|
+
expect(value).toBe('');
|
|
3249
|
+
});
|
|
3250
|
+
it.each([
|
|
3251
|
+
{ rejects: true, reason: 'clear_failed' },
|
|
3252
|
+
{ rejects: false, reason: 'staged_value_stale' },
|
|
3253
|
+
])('restores the pre-clear replacement baseline when async clear rejects=$rejects', async ({ rejects, reason, }) => {
|
|
3254
|
+
let value = 'Ada';
|
|
3255
|
+
let releaseClear;
|
|
3256
|
+
let clearStarted;
|
|
3257
|
+
const blocked = new Promise((resolve) => {
|
|
3258
|
+
releaseClear = resolve;
|
|
3259
|
+
});
|
|
3260
|
+
const started = new Promise((resolve) => {
|
|
3261
|
+
clearStarted = resolve;
|
|
3262
|
+
});
|
|
3263
|
+
const registry = createControlInteractionRegistry({
|
|
3264
|
+
isLocalGesture: () => true,
|
|
3265
|
+
});
|
|
3266
|
+
registry.register({
|
|
3267
|
+
identity,
|
|
3268
|
+
metadata: { kind: 'text' },
|
|
3269
|
+
getValue: () => value,
|
|
3270
|
+
setValue: (next) => {
|
|
3271
|
+
value = String(next);
|
|
3272
|
+
},
|
|
3273
|
+
clear: async () => {
|
|
3274
|
+
value = 'partial';
|
|
3275
|
+
clearStarted?.();
|
|
3276
|
+
await blocked;
|
|
3277
|
+
if (rejects)
|
|
3278
|
+
throw new Error('clear_failed');
|
|
3279
|
+
return true;
|
|
3280
|
+
},
|
|
3281
|
+
});
|
|
3282
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
3283
|
+
const clearing = executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent());
|
|
3284
|
+
await started;
|
|
3285
|
+
registry.register({
|
|
3286
|
+
identity,
|
|
3287
|
+
metadata: { kind: 'text' },
|
|
3288
|
+
getValue: () => value,
|
|
3289
|
+
setValue: (next) => {
|
|
3290
|
+
value = String(next);
|
|
3291
|
+
},
|
|
3292
|
+
restoreValue: (next) => {
|
|
3293
|
+
value = String(next);
|
|
3294
|
+
},
|
|
3295
|
+
});
|
|
3296
|
+
releaseClear?.();
|
|
3297
|
+
expect(await clearing).toMatchObject({ ok: false, reason });
|
|
3298
|
+
expect(value).toBe('Ada');
|
|
3299
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
3300
|
+
});
|
|
3301
|
+
it('preserves a newer replacement user edit equal to a partial clear value', async () => {
|
|
3302
|
+
let value = 'Ada';
|
|
3303
|
+
let releaseClear;
|
|
3304
|
+
let clearStarted;
|
|
3305
|
+
const blocked = new Promise((resolve) => {
|
|
3306
|
+
releaseClear = resolve;
|
|
3307
|
+
});
|
|
3308
|
+
const started = new Promise((resolve) => {
|
|
3309
|
+
clearStarted = resolve;
|
|
3310
|
+
});
|
|
3311
|
+
const registry = createControlInteractionRegistry({
|
|
3312
|
+
isLocalGesture: () => true,
|
|
3313
|
+
});
|
|
3314
|
+
registry.register({
|
|
3315
|
+
identity,
|
|
3316
|
+
metadata: { kind: 'text' },
|
|
3317
|
+
getValue: () => value,
|
|
3318
|
+
getUserEditSnapshot: () => ({ revision: 0, value }),
|
|
3319
|
+
clear: async () => {
|
|
3320
|
+
value = 'partial';
|
|
3321
|
+
clearStarted?.();
|
|
3322
|
+
await blocked;
|
|
3323
|
+
throw new Error('clear_failed');
|
|
3324
|
+
},
|
|
3325
|
+
});
|
|
3326
|
+
const clearing = executeLocalControlCommand(registry, { action: 'clear', identity }, localGestureEvent());
|
|
3327
|
+
await started;
|
|
3328
|
+
registry.register({
|
|
3329
|
+
identity,
|
|
3330
|
+
metadata: { kind: 'text' },
|
|
3331
|
+
getValue: () => value,
|
|
3332
|
+
getUserEditSnapshot: () => ({ revision: 1, value }),
|
|
3333
|
+
setValue: (next) => {
|
|
3334
|
+
value = String(next);
|
|
3335
|
+
},
|
|
3336
|
+
restoreValue: (next) => {
|
|
3337
|
+
value = String(next);
|
|
3338
|
+
},
|
|
3339
|
+
});
|
|
3340
|
+
releaseClear?.();
|
|
3341
|
+
expect(await clearing).toMatchObject({
|
|
3342
|
+
ok: false,
|
|
3343
|
+
reason: 'clear_failed',
|
|
3344
|
+
});
|
|
3345
|
+
expect(value).toBe('partial');
|
|
3346
|
+
});
|
|
3347
|
+
it.each([
|
|
3348
|
+
{ rejects: true, reason: 'undo_failed' },
|
|
3349
|
+
{ rejects: false, reason: 'staged_value_stale' },
|
|
3350
|
+
])('restores the pre-undo replacement baseline when async undo rejects=$rejects', async ({ rejects, reason, }) => {
|
|
3351
|
+
let value = 'Ada';
|
|
3352
|
+
let undoMode = false;
|
|
3353
|
+
let releaseUndo;
|
|
3354
|
+
let undoStarted;
|
|
3355
|
+
const blocked = new Promise((resolve) => {
|
|
3356
|
+
releaseUndo = resolve;
|
|
3357
|
+
});
|
|
3358
|
+
const started = new Promise((resolve) => {
|
|
3359
|
+
undoStarted = resolve;
|
|
3360
|
+
});
|
|
3361
|
+
const registry = createControlInteractionRegistry({
|
|
3362
|
+
isLocalGesture: () => true,
|
|
3363
|
+
});
|
|
3364
|
+
registry.register({
|
|
3365
|
+
identity,
|
|
3366
|
+
metadata: { kind: 'text' },
|
|
3367
|
+
getValue: () => value,
|
|
3368
|
+
setValue: async (next) => {
|
|
3369
|
+
if (!undoMode) {
|
|
3370
|
+
value = String(next);
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
value = 'partial';
|
|
3374
|
+
undoStarted?.();
|
|
3375
|
+
await blocked;
|
|
3376
|
+
if (rejects)
|
|
3377
|
+
throw new Error('undo_failed');
|
|
3378
|
+
value = String(next);
|
|
3379
|
+
},
|
|
3380
|
+
});
|
|
3381
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
3382
|
+
expect(await executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, localGestureEvent())).toMatchObject({ ok: true });
|
|
3383
|
+
undoMode = true;
|
|
3384
|
+
const undoing = executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent());
|
|
3385
|
+
await started;
|
|
3386
|
+
registry.register({
|
|
3387
|
+
identity,
|
|
3388
|
+
metadata: { kind: 'text' },
|
|
3389
|
+
getValue: () => value,
|
|
3390
|
+
setValue: (next) => {
|
|
3391
|
+
value = String(next);
|
|
3392
|
+
},
|
|
3393
|
+
restoreValue: (next) => {
|
|
3394
|
+
value = String(next);
|
|
3395
|
+
},
|
|
3396
|
+
});
|
|
3397
|
+
releaseUndo?.();
|
|
3398
|
+
expect(await undoing).toMatchObject({ ok: false, reason });
|
|
3399
|
+
expect(value).toBe('Grace');
|
|
3400
|
+
expect(await executeLocalControlCommand(registry, { action: 'undo', identity }, localGestureEvent())).toMatchObject({ ok: true });
|
|
3401
|
+
expect(value).toBe('Ada');
|
|
3402
|
+
});
|
|
95
3403
|
});
|