amalgm 0.1.245 → 0.1.246
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/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ROOT exams: identity is permanent and the address is not. Ground that
|
|
5
|
+
* moves is rescued by witnessed continuity (the root keeps its id and
|
|
6
|
+
* every channel), a departure is honest absence, ambiguity refuses to
|
|
7
|
+
* guess, and the smallest ground of all — a single-file root — carries
|
|
8
|
+
* the same laws over one channel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const test = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const Database = require('better-sqlite3');
|
|
18
|
+
const { installFakeWatch } = require('./fake-watch');
|
|
19
|
+
const {
|
|
20
|
+
cleanup, eventsOf, makeDir, makeObserver, nudgeUntil,
|
|
21
|
+
} = require('./observer.rig');
|
|
22
|
+
|
|
23
|
+
test.after(cleanup);
|
|
24
|
+
|
|
25
|
+
test('a root is rescued by continuity: moved ground keeps its rootId and every channel', () => {
|
|
26
|
+
const holder = makeDir('move-holder');
|
|
27
|
+
const A = path.join(holder, 'A');
|
|
28
|
+
fs.mkdirSync(path.join(A, 'sub'), { recursive: true });
|
|
29
|
+
fs.writeFileSync(path.join(A, 'doc.md'), 'doc');
|
|
30
|
+
fs.writeFileSync(path.join(A, 'sub', 'deep.md'), 'deep');
|
|
31
|
+
|
|
32
|
+
const { observer, events } = makeObserver();
|
|
33
|
+
const root = observer.enrollRoot(A);
|
|
34
|
+
const fileIds = observer.filesForRoot(root.rootId).map((row) => row.fileId).sort();
|
|
35
|
+
|
|
36
|
+
fs.renameSync(A, path.join(holder, 'B'));
|
|
37
|
+
|
|
38
|
+
// A same-parent rename discovered by a SCAN — no doorbell delivered
|
|
39
|
+
// anything. The undecided address gets the one shared decision (the
|
|
40
|
+
// same directed look the address doorbell makes), so the scan itself
|
|
41
|
+
// follows: one move, zero unavailable moments, every channel intact.
|
|
42
|
+
// Two independent doorbells can learn of one rename in either order;
|
|
43
|
+
// this is the order where the scan wins, and the verdict is the same.
|
|
44
|
+
const interim = events.length;
|
|
45
|
+
observer.scanNow();
|
|
46
|
+
assert.deepEqual(eventsOf(events.slice(interim), 'root.moved').map((event) => [event.from, event.to]),
|
|
47
|
+
[[A, path.join(holder, 'B')]], 'the scan followed the same-parent rename by witnessed continuity');
|
|
48
|
+
assert.deepEqual(eventsOf(events.slice(interim), 'root.unavailable'), [],
|
|
49
|
+
'a followed move is never an availability transition — whichever entrance decides it');
|
|
50
|
+
assert.deepEqual(observer.filesForRoot(root.rootId).map((row) => row.fileId).sort(), fileIds,
|
|
51
|
+
'every channel rode the follow');
|
|
52
|
+
|
|
53
|
+
// The move the directed look cannot claim: the ground departs beyond
|
|
54
|
+
// the parent. The look is same-parent by design, so the address is
|
|
55
|
+
// undecidable and a scan CONCLUDES NOTHING about identity. Beliefs
|
|
56
|
+
// freeze (a conclusion of "deleted" would end the very channels the
|
|
57
|
+
// rescue preserves), the referee convicts the divergence, and the
|
|
58
|
+
// next enrollment moment naming the new address is what rescues.
|
|
59
|
+
const elsewhere = makeDir('move-elsewhere');
|
|
60
|
+
const C = path.join(elsewhere, 'C');
|
|
61
|
+
fs.renameSync(path.join(holder, 'B'), C);
|
|
62
|
+
const frozen = events.length;
|
|
63
|
+
observer.scanNow();
|
|
64
|
+
assert.deepEqual(events.slice(frozen).map((event) => event.type), ['root.unavailable'],
|
|
65
|
+
'a scan over the departed address concludes NOTHING about identity — its one honest fact is presence');
|
|
66
|
+
observer.scanNow();
|
|
67
|
+
assert.equal(events.length, frozen + 1, 'presence is a transition, never a repeat');
|
|
68
|
+
assert.deepEqual(observer.filesForRoot(root.rootId).map((row) => row.fileId).sort(), fileIds,
|
|
69
|
+
'every channel is intact through the frozen interim');
|
|
70
|
+
assert.ok(observer.verify(root.rootId).length > 0,
|
|
71
|
+
'the referee convicts the divergence honestly while the address is undecided');
|
|
72
|
+
|
|
73
|
+
const before = events.length;
|
|
74
|
+
const rescued = observer.enrollRoot(C);
|
|
75
|
+
assert.equal(rescued.rootId, root.rootId, 'same ground, same root — the address is not the identity');
|
|
76
|
+
assert.equal(rescued.path, C);
|
|
77
|
+
assert.deepEqual(eventsOf(events.slice(before), 'root.moved').map((event) => [event.from, event.to]),
|
|
78
|
+
[[path.join(holder, 'B'), C]], 'the move is one event');
|
|
79
|
+
assert.deepEqual(eventsOf(events.slice(before), 'root.available').map((event) => event.path),
|
|
80
|
+
[C], 'presence restored symmetrically: available announces the return, at the new address');
|
|
81
|
+
assert.deepEqual(observer.filesForRoot(root.rootId).map((row) => row.fileId).sort(), fileIds,
|
|
82
|
+
'every channel survived: a move is an address change, never death and rebirth');
|
|
83
|
+
assert.deepEqual(events.slice(before).filter((event) => event.type.startsWith('file.')), [],
|
|
84
|
+
'and the follow-up scan proves silence');
|
|
85
|
+
assert.equal(observer.listRoots().length, 1, 'no ghost stayed behind at the old address');
|
|
86
|
+
|
|
87
|
+
// The old address reoccupied by a stranger: continuity already claimed
|
|
88
|
+
// the moved ground, and the lookalike is its own new ground.
|
|
89
|
+
fs.mkdirSync(A);
|
|
90
|
+
const stranger = observer.enrollRoot(A);
|
|
91
|
+
assert.notEqual(stranger.rootId, root.rootId, 'presence at an old address proves nothing about identity');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('startup is an entrance too: a same-parent rename made while nothing ran is followed by the catch-up scan', () => {
|
|
95
|
+
// The rename happens while the observer is stopped — no doorbell will
|
|
96
|
+
// ever announce it. Start's catch-up scan finds the undecided address
|
|
97
|
+
// and makes the same decision every entrance makes: witnessed
|
|
98
|
+
// continuity claims the new name, one move, zero unavailable moments.
|
|
99
|
+
// (Beyond the parent the catch-up scan still freezes — proven above —
|
|
100
|
+
// so reattachment remains the deciding act for real departures.)
|
|
101
|
+
const holder = makeDir('offline-rename');
|
|
102
|
+
const A = path.join(holder, 'A');
|
|
103
|
+
fs.mkdirSync(A);
|
|
104
|
+
fs.writeFileSync(path.join(A, 'doc.md'), 'doc');
|
|
105
|
+
const F = path.join(holder, 'F.md');
|
|
106
|
+
fs.writeFileSync(F, 'f');
|
|
107
|
+
|
|
108
|
+
const { observer, events } = makeObserver();
|
|
109
|
+
const dirRoot = observer.enrollRoot(A);
|
|
110
|
+
const fileRoot = observer.enrollRoot(F);
|
|
111
|
+
observer.start();
|
|
112
|
+
observer.stop();
|
|
113
|
+
|
|
114
|
+
fs.renameSync(A, path.join(holder, 'B'));
|
|
115
|
+
fs.renameSync(F, path.join(holder, 'G.md'));
|
|
116
|
+
|
|
117
|
+
const mark = events.length;
|
|
118
|
+
observer.start();
|
|
119
|
+
try {
|
|
120
|
+
assert.deepEqual(eventsOf(events.slice(mark), 'root.moved').map((event) => [event.rootId, event.to]).sort(),
|
|
121
|
+
[[dirRoot.rootId, path.join(holder, 'B')], [fileRoot.rootId, path.join(holder, 'G.md')]].sort(),
|
|
122
|
+
'the catch-up scan followed both kinds by their witnessed identity');
|
|
123
|
+
assert.deepEqual(eventsOf(events.slice(mark), 'root.unavailable'), [],
|
|
124
|
+
'no unavailable moment: continuity never broke, it was merely unobserved');
|
|
125
|
+
const rows = observer.listRoots();
|
|
126
|
+
assert.equal(rows.find((row) => row.rootId === dirRoot.rootId).path, path.join(holder, 'B'));
|
|
127
|
+
assert.equal(rows.find((row) => row.rootId === fileRoot.rootId).path, path.join(holder, 'G.md'));
|
|
128
|
+
} finally {
|
|
129
|
+
observer.stop();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the parent observes address changes: a live rename is followed, a departure is honest absence', async () => {
|
|
134
|
+
const holder = makeDir('addr-live');
|
|
135
|
+
const A = path.join(holder, 'A');
|
|
136
|
+
fs.mkdirSync(A);
|
|
137
|
+
fs.writeFileSync(path.join(A, 'doc.md'), 'doc');
|
|
138
|
+
|
|
139
|
+
const { observer, events } = makeObserver({ settleMs: 40 });
|
|
140
|
+
const root = observer.enrollRoot(A);
|
|
141
|
+
observer.start();
|
|
142
|
+
try {
|
|
143
|
+
// Prime: fs.watch streams start asynchronously, so prove the
|
|
144
|
+
// doorbells are live before staging address changes — a rename fired
|
|
145
|
+
// into a not-yet-live stream would measure the exam's own race, not
|
|
146
|
+
// the law.
|
|
147
|
+
const primed = await nudgeUntil(
|
|
148
|
+
() => fs.writeFileSync(path.join(A, 'prime.md'), `p${Math.random()}`),
|
|
149
|
+
() => events.some((event) => event.type === 'file.created' && event.path === 'prime.md'),
|
|
150
|
+
);
|
|
151
|
+
assert.ok(primed, 'the doorbells are live');
|
|
152
|
+
const fileIds = observer.filesForRoot(root.rootId).map((row) => row.fileId).sort();
|
|
153
|
+
|
|
154
|
+
// The common move: the user renames the folder in place. The parent
|
|
155
|
+
// doorbell rings, the root's own inode identifies it under the usual
|
|
156
|
+
// claim rules, and the rescue follows — the entity observes content,
|
|
157
|
+
// the parent observes the address, and no unavailable moment ever
|
|
158
|
+
// happened because continuity never broke.
|
|
159
|
+
const B = path.join(holder, 'B');
|
|
160
|
+
let renamed = false;
|
|
161
|
+
const followed = await nudgeUntil(
|
|
162
|
+
() => { if (!renamed) { fs.renameSync(A, B); renamed = true; } },
|
|
163
|
+
() => eventsOf(events, 'root.moved').length > 0,
|
|
164
|
+
);
|
|
165
|
+
assert.ok(followed, 'the parent doorbell heard the rename and continuity followed it');
|
|
166
|
+
assert.deepEqual(eventsOf(events, 'root.moved').map((event) => [event.rootId, event.to]),
|
|
167
|
+
[[root.rootId, B]]);
|
|
168
|
+
assert.deepEqual(eventsOf(events, 'root.unavailable'), [],
|
|
169
|
+
'a followed move is never an availability transition');
|
|
170
|
+
assert.deepEqual(observer.filesForRoot(root.rootId).map((row) => row.fileId).sort(), fileIds,
|
|
171
|
+
'every channel rode the follow');
|
|
172
|
+
|
|
173
|
+
// The move the parent cannot follow: the ground leaves for another
|
|
174
|
+
// parent entirely. Presence reports honestly; identity stays frozen
|
|
175
|
+
// for the enrollment moment that eventually names the new address.
|
|
176
|
+
// The re-nudge is a consumer ask (scanNow) — doorbells are lossy BY
|
|
177
|
+
// CONTRACT, and the rescue re-arms its watchers asynchronously, so
|
|
178
|
+
// the absence FACT is what this proves, never stream promptness.
|
|
179
|
+
const elsewhere = makeDir('addr-elsewhere');
|
|
180
|
+
let departed = false;
|
|
181
|
+
const absent = await nudgeUntil(
|
|
182
|
+
() => {
|
|
183
|
+
if (!departed) {
|
|
184
|
+
fs.renameSync(B, path.join(elsewhere, 'C'));
|
|
185
|
+
departed = true;
|
|
186
|
+
} else {
|
|
187
|
+
observer.scanNow(root.rootId);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
() => eventsOf(events, 'root.unavailable').length > 0,
|
|
191
|
+
);
|
|
192
|
+
assert.ok(absent, 'departure to unwatched ground is honest absence, never silence');
|
|
193
|
+
const row = observer.listRoots().find((member) => member.rootId === root.rootId);
|
|
194
|
+
assert.equal(row.present, false, 'presence is separate evidence');
|
|
195
|
+
assert.deepEqual(observer.filesForRoot(root.rootId).map((member) => member.fileId).sort(), fileIds,
|
|
196
|
+
'identity persists through absence: not one channel closed');
|
|
197
|
+
} finally {
|
|
198
|
+
observer.stop();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('the parent doorbell hears every delivery: a rename naming only the NEW name still rings the address check', () => {
|
|
203
|
+
// A rename of A to B may lawfully deliver ONE event naming only "B".
|
|
204
|
+
// A doorbell that filtered for "A" decided the answer before looking
|
|
205
|
+
// and discarded the only live signal address watching has. This exam
|
|
206
|
+
// injects exactly that delivery, deterministically, through a fake
|
|
207
|
+
// fs.watch — the live exam above cannot pin which names the OS sends.
|
|
208
|
+
const { deliver, restore } = installFakeWatch();
|
|
209
|
+
try {
|
|
210
|
+
const holder = makeDir('newname');
|
|
211
|
+
const A = path.join(holder, 'A');
|
|
212
|
+
fs.mkdirSync(A);
|
|
213
|
+
fs.writeFileSync(path.join(A, 'doc.md'), 'doc');
|
|
214
|
+
const F = path.join(holder, 'F.md');
|
|
215
|
+
fs.writeFileSync(F, 'f');
|
|
216
|
+
const { observer, events } = makeObserver();
|
|
217
|
+
const dirRoot = observer.enrollRoot(A);
|
|
218
|
+
const fileRoot = observer.enrollRoot(F);
|
|
219
|
+
observer.start();
|
|
220
|
+
try {
|
|
221
|
+
// The directory root: renamed in place, delivered as the new name
|
|
222
|
+
// only. The address check identifies it by inode and follows —
|
|
223
|
+
// live, with no unavailable moment.
|
|
224
|
+
fs.renameSync(A, path.join(holder, 'B'));
|
|
225
|
+
deliver(holder, 'B');
|
|
226
|
+
assert.deepEqual(eventsOf(events, 'root.moved').map((event) => [event.rootId, event.to]),
|
|
227
|
+
[[dirRoot.rootId, path.join(holder, 'B')]],
|
|
228
|
+
'the new-name delivery was heard and continuity followed it');
|
|
229
|
+
assert.deepEqual(eventsOf(events, 'root.unavailable'), []);
|
|
230
|
+
|
|
231
|
+
// The single-file root: same delivery shape, same law. Its channel
|
|
232
|
+
// row's WITNESSED inode claims the new name under the exactly-one
|
|
233
|
+
// rule, so the rename is followed live too. The atomic save first:
|
|
234
|
+
// the root row's enrollment-time inode is now stale, so only the
|
|
235
|
+
// channel row's identity can make the claim.
|
|
236
|
+
fs.writeFileSync(path.join(holder, '.F.tmp'), 'f2');
|
|
237
|
+
fs.renameSync(path.join(holder, '.F.tmp'), F);
|
|
238
|
+
// THE WITNESS: the scan that makes the channel row current. The
|
|
239
|
+
// follow below rides exactly this evidence — the unwitnessed-burst
|
|
240
|
+
// exam proves that without it, the same rename is honest absence.
|
|
241
|
+
observer.scanNow(fileRoot.rootId);
|
|
242
|
+
fs.renameSync(F, path.join(holder, 'G.md'));
|
|
243
|
+
deliver(holder, 'G.md');
|
|
244
|
+
assert.deepEqual(eventsOf(events, 'root.moved').map((event) => [event.rootId, event.to]),
|
|
245
|
+
[[dirRoot.rootId, path.join(holder, 'B')], [fileRoot.rootId, path.join(holder, 'G.md')]],
|
|
246
|
+
'the file root followed its rename by its channel inode');
|
|
247
|
+
assert.deepEqual(eventsOf(events, 'root.unavailable'), [],
|
|
248
|
+
'no unavailable moment for either kind: continuity never broke');
|
|
249
|
+
const followed = observer.listRoots().find((row) => row.rootId === fileRoot.rootId);
|
|
250
|
+
assert.equal(followed.path, path.join(holder, 'G.md'), 'the address is the new name');
|
|
251
|
+
} finally {
|
|
252
|
+
observer.stop();
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
restore();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('the file follow refuses to guess: ambiguity and departures beyond the parent are honest absence', () => {
|
|
260
|
+
// The exactly-one rule is the whole safety of the file follow. A
|
|
261
|
+
// hardlink beside a rename puts TWO entries on one inode — guessing
|
|
262
|
+
// between them could bind the root to the wrong name — and a move to
|
|
263
|
+
// another directory leaves no candidate at all. Both refuse; absence
|
|
264
|
+
// is honest, and the explicit reattachment that names the new address
|
|
265
|
+
// is the act that decides.
|
|
266
|
+
const { deliver, restore } = installFakeWatch();
|
|
267
|
+
try {
|
|
268
|
+
const holder = makeDir('refuse');
|
|
269
|
+
const elsewhere = makeDir('refuse-elsewhere');
|
|
270
|
+
const F = path.join(holder, 'F.md');
|
|
271
|
+
fs.writeFileSync(F, 'f');
|
|
272
|
+
fs.linkSync(F, path.join(holder, 'H.md')); // a hardlink: same inode, second entry
|
|
273
|
+
const { observer, events } = makeObserver();
|
|
274
|
+
const fileRoot = observer.enrollRoot(F);
|
|
275
|
+
observer.start();
|
|
276
|
+
try {
|
|
277
|
+
// Two candidates wear the root's inode after the rename: refused.
|
|
278
|
+
fs.renameSync(F, path.join(holder, 'G.md'));
|
|
279
|
+
deliver(holder, 'G.md');
|
|
280
|
+
assert.deepEqual(eventsOf(events, 'root.moved'), [], 'ambiguity never rebinds');
|
|
281
|
+
assert.deepEqual(eventsOf(events, 'root.unavailable').map((event) => event.rootId),
|
|
282
|
+
[fileRoot.rootId], 'two entries on one inode is a refusal to guess, live');
|
|
283
|
+
|
|
284
|
+
// Explicit reattachment names the address; the same identity wakes.
|
|
285
|
+
const rescued = observer.enrollRoot(path.join(holder, 'G.md'));
|
|
286
|
+
assert.equal(rescued.rootId, fileRoot.rootId, 'the same identity, reattached');
|
|
287
|
+
assert.deepEqual(eventsOf(events, 'root.available').map((event) => event.rootId),
|
|
288
|
+
[fileRoot.rootId], 'presence is symmetric: the return is announced too');
|
|
289
|
+
|
|
290
|
+
// A departure beyond the parent leaves no candidate: the directed
|
|
291
|
+
// look is same-parent by design, so this is honest absence. (The
|
|
292
|
+
// reattachment above lawfully emitted its own root.moved — the
|
|
293
|
+
// mark scopes these assertions to the departure alone.)
|
|
294
|
+
const mark = events.length;
|
|
295
|
+
fs.unlinkSync(path.join(holder, 'H.md'));
|
|
296
|
+
fs.renameSync(path.join(holder, 'G.md'), path.join(elsewhere, 'G.md'));
|
|
297
|
+
deliver(holder, 'unrelated.txt'); // a foreign name rings the address check directly
|
|
298
|
+
assert.deepEqual(eventsOf(events.slice(mark), 'root.moved'), [], 'no candidate, no rebind');
|
|
299
|
+
assert.deepEqual(eventsOf(events.slice(mark), 'root.unavailable').map((event) => event.rootId),
|
|
300
|
+
[fileRoot.rootId], 'a cross-parent move stays honest absence');
|
|
301
|
+
} finally {
|
|
302
|
+
observer.stop();
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
restore();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('the follow rides witnessed continuity: an unwitnessed atomic-save burst is honest absence, and a re-add is a new identity', () => {
|
|
310
|
+
// Continuity is EVIDENCE, never clairvoyance. An atomic save mints a
|
|
311
|
+
// new inode at the address; until a scan WITNESSES it, the channel row
|
|
312
|
+
// still holds the old one. Rename the file away inside that settle
|
|
313
|
+
// window and the ground at the new name wears an inode nothing ever
|
|
314
|
+
// observed — and its bytes changed in the same burst, so no witness of
|
|
315
|
+
// any kind ties it to the channel. The law refuses to guess: honest
|
|
316
|
+
// absence, live. And the explicit re-add that names the new address
|
|
317
|
+
// enrolls a NEW identity — matching names never prove identity, and
|
|
318
|
+
// with no witnessed binding there is honestly nothing to rescue. The
|
|
319
|
+
// witnessed exam above is the follow promise's true extent.
|
|
320
|
+
const { deliver, restore } = installFakeWatch();
|
|
321
|
+
try {
|
|
322
|
+
const holder = makeDir('unwitnessed');
|
|
323
|
+
const F = path.join(holder, 'F.md');
|
|
324
|
+
fs.writeFileSync(F, 'v1');
|
|
325
|
+
// The settle window, held open: the scheduled witness never runs
|
|
326
|
+
// inside this exam — deterministically, not by racing a timer.
|
|
327
|
+
const { observer, events } = makeObserver({ settleMs: 60_000 });
|
|
328
|
+
const fileRoot = observer.enrollRoot(F);
|
|
329
|
+
observer.start();
|
|
330
|
+
try {
|
|
331
|
+
fs.writeFileSync(path.join(holder, '.F.tmp'), 'v2');
|
|
332
|
+
fs.renameSync(path.join(holder, '.F.tmp'), F); // the unwitnessed save: new inode, new bytes
|
|
333
|
+
deliver(holder, 'F.md'); // schedules the witness — held open above
|
|
334
|
+
fs.renameSync(F, path.join(holder, 'G.md'));
|
|
335
|
+
deliver(holder, 'G.md');
|
|
336
|
+
assert.deepEqual(eventsOf(events, 'root.moved'), [], 'no witnessed evidence, no follow');
|
|
337
|
+
assert.deepEqual(eventsOf(events, 'root.unavailable').map((event) => event.rootId),
|
|
338
|
+
[fileRoot.rootId], 'the unwitnessed burst is honest absence, live');
|
|
339
|
+
const row = observer.listRoots().find((member) => member.rootId === fileRoot.rootId);
|
|
340
|
+
assert.equal(row.path, F, 'identity frozen at its recorded address');
|
|
341
|
+
const readded = observer.enrollRoot(path.join(holder, 'G.md'));
|
|
342
|
+
assert.notEqual(readded.rootId, fileRoot.rootId,
|
|
343
|
+
'nothing witnessed ties the new ground to the old channel: the re-add is honestly a new identity');
|
|
344
|
+
} finally {
|
|
345
|
+
observer.stop();
|
|
346
|
+
}
|
|
347
|
+
} finally {
|
|
348
|
+
restore();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('a family moves as one durable transition: a failure mid-move tears nothing', () => {
|
|
353
|
+
// Inject a failure into the second root-path write. Without one
|
|
354
|
+
// transaction over the whole family this tears the store — the parent
|
|
355
|
+
// at the new address, a nested territory left behind at the old.
|
|
356
|
+
const db = new Database(':memory:');
|
|
357
|
+
const realPrepare = db.prepare.bind(db);
|
|
358
|
+
let failAfter = Infinity;
|
|
359
|
+
db.prepare = (sql) => {
|
|
360
|
+
const stmt = realPrepare(sql);
|
|
361
|
+
if (!/UPDATE observer_roots SET path/.test(sql)) return stmt;
|
|
362
|
+
const realRun = stmt.run.bind(stmt);
|
|
363
|
+
stmt.run = (...args) => {
|
|
364
|
+
if (--failAfter < 0) throw new Error('injected: failure mid-move');
|
|
365
|
+
return realRun(...args);
|
|
366
|
+
};
|
|
367
|
+
return stmt;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const holder = makeDir('torn-holder');
|
|
371
|
+
const A = path.join(holder, 'A');
|
|
372
|
+
fs.mkdirSync(path.join(A, 'vendor'), { recursive: true });
|
|
373
|
+
execFileSync('git', ['init', '-q'], { cwd: path.join(A, 'vendor') });
|
|
374
|
+
const { observer } = makeObserver({ db });
|
|
375
|
+
const root = observer.enrollRoot(A);
|
|
376
|
+
const oldPaths = observer.listRoots().map((row) => row.path).sort();
|
|
377
|
+
assert.equal(oldPaths.length, 2, 'the family: the root and its nested territory');
|
|
378
|
+
|
|
379
|
+
const B = path.join(holder, 'B');
|
|
380
|
+
fs.renameSync(A, B);
|
|
381
|
+
failAfter = 1; // the first write lands, the second throws
|
|
382
|
+
assert.throws(() => observer.enrollRoot(B), /injected/);
|
|
383
|
+
assert.deepEqual(observer.listRoots().map((row) => row.path).sort(), oldPaths,
|
|
384
|
+
'nothing tore: the whole family still holds the previous truth');
|
|
385
|
+
|
|
386
|
+
failAfter = Infinity;
|
|
387
|
+
const rescued = observer.enrollRoot(B);
|
|
388
|
+
assert.equal(rescued.rootId, root.rootId, 'the retry is the same rescue, whole');
|
|
389
|
+
assert.ok(observer.listRoots().every((row) => row.path === B || row.path.startsWith(`${B}/`)),
|
|
390
|
+
'and the whole family lives at the new address');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('a reoccupied address concludes nothing until named: scans freeze, the referee convicts, re-adding adopts', () => {
|
|
394
|
+
const holder = makeDir('reoccupy-holder');
|
|
395
|
+
const A = path.join(holder, 'A');
|
|
396
|
+
fs.mkdirSync(A);
|
|
397
|
+
fs.writeFileSync(path.join(A, 'old.md'), 'old');
|
|
398
|
+
|
|
399
|
+
const { observer, events } = makeObserver();
|
|
400
|
+
const root = observer.enrollRoot(A);
|
|
401
|
+
const fileIds = observer.filesForRoot(root.rootId).map((row) => row.fileId);
|
|
402
|
+
|
|
403
|
+
// The ground is destroyed and different ground reoccupies the address.
|
|
404
|
+
// Presence proves nothing about identity: the scan cannot tell a
|
|
405
|
+
// reoccupation from a move-away-plus-lookalike, so it concludes
|
|
406
|
+
// nothing and the referee carries the conviction.
|
|
407
|
+
fs.rmSync(A, { recursive: true });
|
|
408
|
+
fs.mkdirSync(A);
|
|
409
|
+
fs.writeFileSync(path.join(A, 'new.md'), 'new');
|
|
410
|
+
const interim = events.length;
|
|
411
|
+
observer.scanNow();
|
|
412
|
+
assert.deepEqual(events.slice(interim).map((event) => event.type), ['root.unavailable'],
|
|
413
|
+
'frozen: no identity conclusions at an undecided address — one presence fact, nothing more');
|
|
414
|
+
assert.deepEqual(observer.filesForRoot(root.rootId).map((row) => row.fileId), fileIds,
|
|
415
|
+
'the recorded channels stay — deletion is never inferred at the horizon\'s edge');
|
|
416
|
+
assert.ok(observer.verify(root.rootId).length > 0, 'the referee convicts the divergence');
|
|
417
|
+
|
|
418
|
+
// Naming the address again is the deliberate act that resolves it:
|
|
419
|
+
// the root adopts the ground it was told is its own, and a full scan
|
|
420
|
+
// turns the frozen beliefs into honest evidence.
|
|
421
|
+
const adopted = observer.enrollRoot(A);
|
|
422
|
+
assert.equal(adopted.rootId, root.rootId, 'adoption keeps the root: identity is the enrollment, not the inode');
|
|
423
|
+
assert.deepEqual(observer.filesForRoot(root.rootId).map((row) => row.relPath), ['new.md'],
|
|
424
|
+
'and the adopting scan reconciles the new ground honestly');
|
|
425
|
+
assert.deepEqual(observer.verify(root.rootId), [], 'settled truth');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// --- single-file roots: the smallest ground -------------------------------
|
|
429
|
+
|
|
430
|
+
test('a single file is the smallest root: one channel, the file laws, path-first identity', () => {
|
|
431
|
+
const dir = makeDir('solo');
|
|
432
|
+
const filePath = path.join(dir, 'notes.md');
|
|
433
|
+
fs.writeFileSync(filePath, 'v1');
|
|
434
|
+
|
|
435
|
+
const { observer, events } = makeObserver();
|
|
436
|
+
const root = observer.enrollRoot(filePath);
|
|
437
|
+
assert.equal(root.kind, 'file');
|
|
438
|
+
assert.deepEqual(eventsOf(events, 'file.created').map((event) => [event.path, event.rootId]),
|
|
439
|
+
[['', root.rootId]], 'enrollment births the one channel; its address is the root itself');
|
|
440
|
+
const fileId = eventsOf(events, 'file.created')[0].fileId;
|
|
441
|
+
|
|
442
|
+
fs.writeFileSync(filePath, 'v2');
|
|
443
|
+
observer.scanNow(root.rootId);
|
|
444
|
+
assert.equal(eventsOf(events, 'file.changed').length, 1, 'a content change is one event');
|
|
445
|
+
|
|
446
|
+
// An atomic save: a fresh inode renamed over the path. Path-first
|
|
447
|
+
// identity absorbs the churn — same channel, one change event.
|
|
448
|
+
fs.writeFileSync(path.join(dir, '.notes.tmp'), 'v3');
|
|
449
|
+
fs.renameSync(path.join(dir, '.notes.tmp'), filePath);
|
|
450
|
+
observer.scanNow(root.rootId);
|
|
451
|
+
const changes = eventsOf(events, 'file.changed');
|
|
452
|
+
assert.equal(changes.length, 2);
|
|
453
|
+
assert.ok(changes.every((event) => event.fileId === fileId), 'the channel survived the inode churn');
|
|
454
|
+
assert.deepEqual(observer.verify(root.rootId), [], 'the referee agrees with the observer');
|
|
455
|
+
|
|
456
|
+
fs.writeFileSync(filePath, 'v4-behind-its-back');
|
|
457
|
+
assert.deepEqual(observer.verify(root.rootId).map((m) => m.problem),
|
|
458
|
+
['stale: disk moved, the observer did not notice'],
|
|
459
|
+
'the referee re-hashes the one path with none of the observer\'s machinery');
|
|
460
|
+
observer.scanNow(root.rootId);
|
|
461
|
+
|
|
462
|
+
// Absence at the root's OWN address is the horizon's edge: moved away
|
|
463
|
+
// and deleted look identical from here, so the scan concludes nothing —
|
|
464
|
+
// the channel freezes, the referee convicts, and only an enrollment
|
|
465
|
+
// moment or explicit removal decides.
|
|
466
|
+
fs.rmSync(filePath);
|
|
467
|
+
observer.scanNow(root.rootId);
|
|
468
|
+
assert.deepEqual(eventsOf(events, 'file.deleted'), [],
|
|
469
|
+
'absent root ground closes nothing: deletion is never inferred at the horizon\'s edge');
|
|
470
|
+
assert.deepEqual(eventsOf(events, 'root.unavailable').map((event) => event.rootId), [root.rootId],
|
|
471
|
+
'identity persists; local presence is separate evidence — absence is one honest fact');
|
|
472
|
+
assert.deepEqual(observer.verify(root.rootId).map((m) => m.problem),
|
|
473
|
+
['ghost: the observer believes in a file disk no longer has'],
|
|
474
|
+
'the referee convicts the divergence instead');
|
|
475
|
+
|
|
476
|
+
// Recreation under the same address is the same channel: the file laws
|
|
477
|
+
// absorb the churn, and continuity never broke.
|
|
478
|
+
fs.writeFileSync(filePath, 'v5-recreated');
|
|
479
|
+
observer.scanNow(root.rootId);
|
|
480
|
+
const last = eventsOf(events, 'file.changed').at(-1);
|
|
481
|
+
assert.equal(last.fileId, fileId, 'the channel outlived the gap: path-first identity claims the recreation');
|
|
482
|
+
assert.deepEqual(eventsOf(events, 'root.available').map((event) => event.rootId), [root.rootId],
|
|
483
|
+
'presence is symmetric: the return is announced, never inferred from unrelated events');
|
|
484
|
+
assert.deepEqual(observer.verify(root.rootId), [], 'and the referee sees settled truth');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('the parent-directory doorbell hears a single-file root live', async () => {
|
|
488
|
+
const dir = makeDir('solo-live');
|
|
489
|
+
const filePath = path.join(dir, 'watched.md');
|
|
490
|
+
fs.writeFileSync(filePath, 'first');
|
|
491
|
+
|
|
492
|
+
const { observer, events } = makeObserver({ settleMs: 40 });
|
|
493
|
+
const root = observer.enrollRoot(filePath);
|
|
494
|
+
observer.start();
|
|
495
|
+
try {
|
|
496
|
+
const heard = await nudgeUntil(
|
|
497
|
+
() => fs.writeFileSync(filePath, 'second'),
|
|
498
|
+
() => eventsOf(events, 'file.changed').length > 0,
|
|
499
|
+
);
|
|
500
|
+
assert.ok(heard, 'a write reached the observer through the parent-directory doorbell');
|
|
501
|
+
assert.equal(eventsOf(events, 'file.changed')[0].rootId, root.rootId);
|
|
502
|
+
} finally {
|
|
503
|
+
observer.stop();
|
|
504
|
+
}
|
|
505
|
+
});
|