pinokiod 8.0.39 → 8.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/kernel/api/hf/index.js +2 -2
  2. package/kernel/api/index.js +12 -3
  3. package/kernel/connect/index.js +2 -2
  4. package/kernel/connect/providers/huggingface/index.js +14 -5
  5. package/kernel/index.js +14 -2
  6. package/kernel/shell.js +3 -2
  7. package/kernel/shells.js +6 -0
  8. package/kernel/vault/constants.js +13 -0
  9. package/kernel/vault/hash_worker.js +9 -2
  10. package/kernel/vault/index.js +1856 -316
  11. package/kernel/vault/registry.js +526 -52
  12. package/kernel/vault/snapshot.js +29 -0
  13. package/kernel/vault/sweeper.js +480 -210
  14. package/kernel/vault/walker.js +142 -0
  15. package/package.json +1 -1
  16. package/server/index.js +79 -90
  17. package/server/lib/privacy_filter_cache.js +4 -1
  18. package/server/public/storage-size.js +12 -0
  19. package/server/public/style.css +13 -0
  20. package/server/public/tab-link-popover.js +3 -0
  21. package/server/public/vault-nav.js +96 -0
  22. package/server/public/vault.css +1079 -0
  23. package/server/public/vault.js +1835 -0
  24. package/server/views/app.ejs +93 -16
  25. package/server/views/connect/huggingface.ejs +7 -9
  26. package/server/views/partials/main_sidebar.ejs +6 -1
  27. package/server/views/partials/vault_workspace.ejs +55 -0
  28. package/server/views/vault.ejs +5 -1532
  29. package/server/views/vault_app.ejs +22 -0
  30. package/test/hf-api.test.js +4 -2
  31. package/test/huggingface-connect.test.js +7 -11
  32. package/test/huggingface-token-validity.test.js +135 -0
  33. package/test/plugin-action-functions.test.js +19 -0
  34. package/test/privacy-filter-cache.test.js +1 -0
  35. package/test/vault-engine.test.js +1276 -26
  36. package/test/vault-sweep.test.js +1043 -35
  37. package/test/vault-ui.test.js +2184 -65
@@ -4,6 +4,8 @@ const fs = require('fs')
4
4
  const os = require('os')
5
5
  const path = require('path')
6
6
  const crypto = require('crypto')
7
+ const ejs = require('ejs')
8
+ const { JSDOM } = require('jsdom')
7
9
  const Vault = require('../kernel/vault')
8
10
 
9
11
  const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
@@ -14,6 +16,29 @@ const writeFile = async (p, content) => {
14
16
  return p
15
17
  }
16
18
 
19
+ const duplicateEntry = async (filePath, entry) => {
20
+ const st = await fs.promises.stat(filePath)
21
+ return Object.assign({}, entry, {
22
+ dev: st.dev,
23
+ ino: st.ino,
24
+ mtime: st.mtimeMs,
25
+ ctime: st.ctimeMs
26
+ })
27
+ }
28
+
29
+ const vaultPageSource = async () => {
30
+ const root = path.resolve(__dirname, '..', 'server')
31
+ const files = ['views/vault.ejs', 'views/partials/vault_workspace.ejs', 'public/vault.css', 'public/storage-size.js', 'public/vault.js']
32
+ return (await Promise.all(files.map((file) => fs.promises.readFile(path.resolve(root, file), 'utf8')))).join('\n')
33
+ }
34
+
35
+ const runVaultScript = async (dom) => {
36
+ const publicRoot = path.resolve(__dirname, '..', 'server', 'public')
37
+ const scripts = await Promise.all(['storage-size.js', 'vault.js']
38
+ .map((file) => fs.promises.readFile(path.resolve(publicRoot, file), 'utf8')))
39
+ dom.window.eval(scripts.join('\n'))
40
+ }
41
+
17
42
  describe('vault dashboard backend (phase 4)', () => {
18
43
  let homes = []
19
44
  const makeEnv = async () => {
@@ -44,20 +69,89 @@ describe('vault dashboard backend (phase 4)', () => {
44
69
  test('item 15: undo of a conversion batch restores independent files', async () => {
45
70
  const { home, vault } = await makeEnv()
46
71
  const { content, a, b } = await makeSharedPair(home, vault)
47
- const savedBefore = vault.registry.totals.lifetime_bytes_saved
48
72
 
49
73
  const result = await vault.undoBatch('batch-m.bin')
50
74
  assert.strictEqual(result.undone, 1)
51
75
  assert.strictEqual((await fs.promises.stat(b)).nlink, 1, 'independent file again')
52
76
  assert.deepStrictEqual(await fs.promises.readFile(b), content)
53
77
  assert.strictEqual((await fs.promises.stat(a)).nlink, 2, 'other names untouched')
54
- assert.strictEqual(vault.registry.totals.lifetime_bytes_saved, savedBefore - content.length)
55
78
  assert.ok(vault.registry.duplicates.has(b), 'undone file is pending again')
56
79
  const events = await vault.registry.readEvents()
57
80
  assert.ok(events.some((e) => e.kind === 'undo' && e.batch_id === 'batch-m.bin'))
58
81
  })
59
82
 
60
- test('detach makes one name independent and pins it against future scans', async () => {
83
+ test('undo remains available after its activity event is compacted away', async () => {
84
+ const { home, vault } = await makeEnv()
85
+ const { b } = await makeSharedPair(home, vault, 'retained.bin')
86
+ vault.registry.maxEvents = 2
87
+ vault.registry.compactEvery = 1
88
+
89
+ for (let index = 0; index < 3; index += 1) {
90
+ await vault.registry.appendEvent({ kind: 'scan', marker: index })
91
+ }
92
+
93
+ const compactedEvents = await vault.registry.readEvents()
94
+ assert.strictEqual(compactedEvents.some((event) => event.batch_id === 'batch-retained.bin'), false)
95
+ await vault.sweeper.scan()
96
+ assert.strictEqual(vault.registry.links.get(b).batch_id, 'batch-retained.bin')
97
+ const status = await vault.status()
98
+ assert.deepStrictEqual(status.undo_batches, [{
99
+ batch_id: 'batch-retained.bin', files: 1, bytes: 4096, ts: null
100
+ }])
101
+
102
+ const result = await vault.undoBatch('batch-retained.bin')
103
+ assert.strictEqual(result.undone, 1)
104
+ assert.strictEqual((await fs.promises.stat(b)).nlink, 1)
105
+ })
106
+
107
+ test('a replaced file never inherits an old compacted undo batch', async () => {
108
+ const { home, vault } = await makeEnv()
109
+ const { b } = await makeSharedPair(home, vault, 'replaced.bin')
110
+ const oldBatch = 'batch-replaced.bin'
111
+ vault.registry.maxEvents = 2
112
+ vault.registry.compactEvery = 1
113
+
114
+ for (let index = 0; index < 3; index += 1) {
115
+ await vault.registry.appendEvent({ kind: 'scan', marker: index })
116
+ }
117
+ assert.strictEqual((await vault.registry.readEvents())
118
+ .some((event) => event.batch_id === oldBatch), false)
119
+
120
+ const replacement = crypto.randomBytes(4096)
121
+ const replacementPath = await writeFile(`${b}.replacement`, replacement)
122
+ await fs.promises.rename(replacementPath, b)
123
+ await vault.sweeper.scan()
124
+
125
+ const replacementStat = await fs.promises.stat(b)
126
+ const replacementEntry = vault.registry.links.get(b)
127
+ assert.strictEqual(replacementEntry.hash, sha256(replacement))
128
+ assert.strictEqual(replacementEntry.batch_id, undefined)
129
+
130
+ const result = await vault.undoBatch(oldBatch)
131
+ assert.deepStrictEqual(result, { undone: 0, bytes: 0, failed: 0 })
132
+ assert.deepStrictEqual(await fs.promises.readFile(b), replacement)
133
+ assert.strictEqual((await fs.promises.stat(b)).ino, replacementStat.ino)
134
+ })
135
+
136
+ test('an old activity batch cannot undo a newer conversion of the same path', async () => {
137
+ const { home, vault } = await makeEnv()
138
+ const { hash, b } = await makeSharedPair(home, vault, 'reconverted.bin')
139
+ assert.strictEqual((await vault.undoBatch('batch-reconverted.bin')).undone, 1)
140
+ assert.strictEqual((await vault.convert(b, hash, { app: 'appB', batch_id: 'batch-new' })).status, 'converted')
141
+
142
+ const oldUndo = await vault.undoBatch('batch-reconverted.bin')
143
+ assert.strictEqual(oldUndo.undone, 0)
144
+ assert.strictEqual(vault.registry.links.get(b).batch_id, 'batch-new')
145
+ assert.ok((await fs.promises.stat(b)).nlink > 1)
146
+
147
+ const status = await vault.status()
148
+ const oldEvent = status.events.find((event) => event.kind === 'convert' && event.batch_id === 'batch-reconverted.bin')
149
+ const newEvent = status.events.find((event) => event.kind === 'convert' && event.batch_id === 'batch-new')
150
+ assert.strictEqual(oldEvent.undoable, false)
151
+ assert.strictEqual(newEvent.undoable, true)
152
+ })
153
+
154
+ test('detach makes one shared name independent and immediately pending again', async () => {
61
155
  const { home, vault } = await makeEnv()
62
156
  const { content, a, b } = await makeSharedPair(home, vault)
63
157
 
@@ -66,24 +160,405 @@ describe('vault dashboard backend (phase 4)', () => {
66
160
  assert.strictEqual((await fs.promises.stat(b)).nlink, 1)
67
161
  assert.deepStrictEqual(await fs.promises.readFile(b), content)
68
162
  assert.strictEqual((await fs.promises.stat(a)).nlink, 2)
69
- assert.ok(vault.registry.excluded.has(b))
70
- assert.strictEqual(vault.registry.excluded.get(b).size, content.length)
163
+ assert.strictEqual(vault.registry.excluded.has(b), false)
164
+ assert.ok(vault.registry.duplicates.has(b))
165
+ assert.strictEqual(vault.registry.duplicates.get(b).size, content.length)
166
+ assert.ok(vault.registry.scanIndex.has(b))
71
167
  assert.strictEqual(fs.existsSync(b + '.pinokio-dedup-tmp'), false)
72
168
 
73
- // A scan neither re-adopts nor re-lists the pinned path.
169
+ // No re-share action or extra scan is required to review it again.
170
+ const status = await vault.status()
171
+ assert.ok(status.duplicates.some((item) => item.path === b))
172
+
173
+ // A later scan preserves the pending duplicate.
74
174
  await vault.sweeper.scan()
75
175
  assert.strictEqual((await fs.promises.stat(b)).nlink, 1)
176
+ assert.ok(vault.registry.duplicates.has(b))
177
+ })
178
+
179
+ test('detach reports actual copied bytes and clears progress after the atomic replacement', async () => {
180
+ const { home, vault } = await makeEnv()
181
+ const size = (17 * 1024 * 1024) + 123
182
+ const content = Buffer.alloc(size, 0x5a)
183
+ const hash = sha256(content)
184
+ const a = await writeFile(path.resolve(home, 'api', 'appA', 'large.bin'), content)
185
+ const b = await writeFile(path.resolve(home, 'api', 'appB', 'large.bin'), content)
186
+ await vault.refreshSources()
187
+ const appA = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
188
+ const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
189
+ await vault.adopt(a, hash, { app: 'appA', source_id: appA.id })
190
+ assert.strictEqual((await vault.convert(b, hash, {
191
+ app: 'appB', source_id: appB.id, batch_id: 'large'
192
+ })).status, 'converted')
193
+
194
+ const samples = []
195
+ const realCopyOut = vault.copyOut.bind(vault)
196
+ vault.copyOut = async (...args) => {
197
+ const options = args[4]
198
+ const onProgress = options.onProgress
199
+ args[4] = Object.assign({}, options, {
200
+ onProgress: (copied, total) => {
201
+ onProgress(copied, total)
202
+ samples.push(vault.progressStatus(appB.id).file_action)
203
+ assert.strictEqual(vault.progressStatus(appA.id).file_action, null)
204
+ }
205
+ })
206
+ return realCopyOut(...args)
207
+ }
208
+
209
+ const result = await vault.detach(b)
210
+
211
+ assert.strictEqual(result.status, 'detached')
212
+ assert.ok(samples.length >= 4)
213
+ assert.strictEqual(samples[0].bytes_copied, 0)
214
+ assert.strictEqual(samples.at(-1).bytes_copied, size)
215
+ assert.ok(samples.every((sample) =>
216
+ sample.kind === 'detach' &&
217
+ sample.path === b &&
218
+ sample.source_id === appB.id &&
219
+ sample.bytes_total === size))
220
+ for (let index = 1; index < samples.length; index += 1) {
221
+ assert.ok(samples[index].bytes_copied > samples[index - 1].bytes_copied)
222
+ }
223
+ assert.strictEqual(vault.progressStatus(appB.id).file_action, null)
224
+ assert.strictEqual((await fs.promises.stat(b)).nlink, 1)
225
+ assert.deepStrictEqual(await fs.promises.readFile(b), content)
226
+ })
227
+
228
+ test('detach removes its partial copy and preserves sharing when the disk write fails', async () => {
229
+ const { home, vault } = await makeEnv()
230
+ const size = (9 * 1024 * 1024) + 17
231
+ const content = Buffer.alloc(size, 0x39)
232
+ const hash = sha256(content)
233
+ const a = await writeFile(path.resolve(home, 'api', 'appA', 'no-space.bin'), content)
234
+ await vault.adopt(a, hash, { app: 'appA' })
235
+ const b = await writeFile(path.resolve(home, 'api', 'appB', 'no-space.bin'), content)
236
+ assert.strictEqual((await vault.convert(b, hash, {
237
+ app: 'appB', batch_id: 'no-space'
238
+ })).status, 'converted')
239
+ const before = await fs.promises.stat(b)
240
+ const tmp = b + Vault.TMP_SUFFIX
241
+ const realOpen = fs.promises.open
242
+ fs.promises.open = async (...args) => {
243
+ const handle = await realOpen(...args)
244
+ if (args[0] !== tmp) return handle
245
+ return {
246
+ stat: (...callArgs) => handle.stat(...callArgs),
247
+ chmod: (...callArgs) => handle.chmod(...callArgs),
248
+ close: (...callArgs) => handle.close(...callArgs),
249
+ write: async (...callArgs) => {
250
+ await handle.write(...callArgs)
251
+ const error = new Error('no space left on device')
252
+ error.code = 'ENOSPC'
253
+ throw error
254
+ }
255
+ }
256
+ }
257
+
258
+ try {
259
+ await assert.rejects(vault.detach(b), (error) => error && error.code === 'ENOSPC')
260
+ } finally {
261
+ fs.promises.open = realOpen
262
+ }
263
+
264
+ const after = await fs.promises.stat(b)
265
+ assert.strictEqual(after.dev, before.dev)
266
+ assert.strictEqual(after.ino, before.ino)
267
+ assert.deepStrictEqual(await fs.promises.readFile(b), content)
268
+ assert.strictEqual(fs.existsSync(tmp), false)
269
+ assert.ok(vault.registry.links.has(b))
76
270
  assert.strictEqual(vault.registry.duplicates.has(b), false)
271
+ assert.strictEqual(vault.fileAction, null)
272
+ })
77
273
 
78
- // Re-share un-pins; the next scan lists it as pending again (no auto-convert).
79
- const reshared = await vault.reshare(b)
80
- assert.strictEqual(reshared.status, 'resharable')
81
- await vault.sweeper.scan()
274
+ test('a committed file action reports a persistence warning instead of a false failure', async () => {
275
+ const { home, vault } = await makeEnv()
276
+ const { content, b } = await makeSharedPair(home, vault, 'persist-warning.bin')
277
+ const before = await fs.promises.stat(b)
278
+ const realFlush = vault.registry.flush.bind(vault.registry)
279
+ vault.registry.persistDelay = 60000
280
+ vault.registry.flush = async () => {
281
+ const error = new Error('temporary snapshot write failure')
282
+ error.code = 'EIO'
283
+ throw error
284
+ }
285
+
286
+ let result
287
+ try {
288
+ result = await vault.perform('detach', { path: b })
289
+ } finally {
290
+ vault.registry.flush = realFlush
291
+ }
292
+
293
+ const after = await fs.promises.stat(b)
294
+ assert.strictEqual(result.status, 'detached')
295
+ assert.strictEqual(result.persistence_warning, true)
296
+ assert.notStrictEqual(after.ino, before.ino)
297
+ assert.deepStrictEqual(await fs.promises.readFile(b), content)
82
298
  assert.ok(vault.registry.duplicates.has(b))
299
+ assert.strictEqual(vault.registry.excluded.has(b), false)
300
+ await vault.registry.flush()
301
+ })
302
+
303
+ test('duplicate queued detach requests cannot turn a pending file into Skip', async () => {
304
+ const { home, vault } = await makeEnv()
305
+ const { b } = await makeSharedPair(home, vault, 'queued-twice.bin')
306
+
307
+ const [first, second] = await Promise.all([
308
+ vault.perform('detach', { path: b }),
309
+ vault.perform('detach', { path: b })
310
+ ])
311
+
312
+ assert.strictEqual(first.status, 'detached')
313
+ assert.strictEqual(second.status, 'not-found')
314
+ assert.ok(vault.registry.duplicates.has(b))
315
+ assert.strictEqual(vault.registry.excluded.has(b), false)
316
+ })
317
+
318
+ test('detach and undo preserve an unrelated temporary-name collision', async () => {
319
+ const { home, vault } = await makeEnv()
320
+ const { b } = await makeSharedPair(home, vault)
321
+ const tmp = b + Vault.TMP_SUFFIX
322
+ await fs.promises.writeFile(tmp, 'user data')
323
+
324
+ const detached = await vault.detach(b)
325
+ const undone = await vault.undoBatch('batch-m.bin')
326
+
327
+ assert.strictEqual(detached.status, 'conflict')
328
+ assert.strictEqual(undone.undone, 0)
329
+ assert.strictEqual(undone.failed, 1)
330
+ assert.strictEqual(await fs.promises.readFile(tmp, 'utf8'), 'user data')
331
+ assert.ok(vault.registry.links.has(b))
332
+ })
333
+
334
+ test('detach never overwrites a file replaced while its independent copy is being made', async () => {
335
+ const { home, vault } = await makeEnv()
336
+ const { b } = await makeSharedPair(home, vault)
337
+ const replacement = Buffer.from('newer-writer-content')
338
+ const realLstat = fs.promises.lstat
339
+ let replaced = false
340
+ fs.promises.lstat = async (target, options) => {
341
+ if (!replaced && path.resolve(String(target)) === b + Vault.TMP_SUFFIX) {
342
+ replaced = true
343
+ const writerPath = b + '.writer-replacement'
344
+ await fs.promises.writeFile(writerPath, replacement)
345
+ await fs.promises.rename(writerPath, b)
346
+ }
347
+ return realLstat(target, options)
348
+ }
349
+ let result
350
+ try {
351
+ result = await vault.detach(b)
352
+ } finally {
353
+ fs.promises.lstat = realLstat
354
+ }
355
+
356
+ assert.strictEqual(result.status, 'stale')
357
+ assert.deepStrictEqual(await fs.promises.readFile(b), replacement)
358
+ assert.strictEqual(fs.existsSync(b + Vault.TMP_SUFFIX), false)
359
+ assert.strictEqual(vault.registry.excluded.has(b), false)
360
+ })
361
+
362
+ test('detach preserves a temporary path replaced before rename', async () => {
363
+ const { home, vault } = await makeEnv()
364
+ const { content, b } = await makeSharedPair(home, vault)
365
+ const tmp = b + Vault.TMP_SUFFIX
366
+ const savedCopy = tmp + '.saved-copy'
367
+ const realLstat = fs.promises.lstat
368
+ let tmpReads = 0
369
+ fs.promises.lstat = async (target, options) => {
370
+ if (path.resolve(String(target)) === tmp) {
371
+ tmpReads += 1
372
+ if (tmpReads === 2) {
373
+ await fs.promises.rename(tmp, savedCopy)
374
+ await fs.promises.writeFile(tmp, 'unrelated')
375
+ }
376
+ }
377
+ return realLstat(target, options)
378
+ }
379
+ let result
380
+ try {
381
+ result = await vault.detach(b)
382
+ } finally {
383
+ fs.promises.lstat = realLstat
384
+ }
385
+
386
+ assert.strictEqual(result.status, 'stale')
387
+ assert.deepStrictEqual(await fs.promises.readFile(b), content)
388
+ assert.strictEqual(await fs.promises.readFile(tmp, 'utf8'), 'unrelated')
389
+ assert.strictEqual(vault.registry.excluded.has(b), false)
390
+ })
391
+
392
+ test('detach and undo stop when the owning app starts during the copy', async () => {
393
+ const { home, vault } = await makeEnv()
394
+ const { b } = await makeSharedPair(home, vault)
395
+ const realLstat = fs.promises.lstat
396
+ let startDuringCopy = true
397
+ fs.promises.lstat = async (target, options) => {
398
+ if (startDuringCopy && path.resolve(String(target)) === b + Vault.TMP_SUFFIX) {
399
+ startDuringCopy = false
400
+ vault.kernel.api = {
401
+ running: { started: true },
402
+ running_paths: { started: path.resolve(home, 'api', 'appB', 'start.js') }
403
+ }
404
+ }
405
+ return realLstat(target, options)
406
+ }
407
+ let detached
408
+ try {
409
+ detached = await vault.detach(b)
410
+ vault.kernel.api = null
411
+ startDuringCopy = true
412
+ const undone = await vault.undoBatch('batch-m.bin')
413
+ assert.strictEqual(undone.undone, 0)
414
+ assert.strictEqual(undone.failed, 1)
415
+ } finally {
416
+ fs.promises.lstat = realLstat
417
+ }
418
+
419
+ assert.strictEqual(detached.status, 'locked')
420
+ assert.ok((await fs.promises.stat(b)).nlink > 1)
421
+ assert.strictEqual(fs.existsSync(b + Vault.TMP_SUFFIX), false)
422
+ })
423
+
424
+ test('detach does not register a writer replacement that wins after the atomic rename', async () => {
425
+ const { home, vault } = await makeEnv()
426
+ const { b } = await makeSharedPair(home, vault)
427
+ const replacement = Buffer.from('newer-writer-content')
428
+ const realRename = fs.promises.rename
429
+ fs.promises.rename = async (source, target) => {
430
+ const result = await realRename(source, target)
431
+ if (source === b + Vault.TMP_SUFFIX && target === b) {
432
+ const writerPath = b + '.writer-replacement'
433
+ await fs.promises.writeFile(writerPath, replacement)
434
+ await realRename(writerPath, b)
435
+ }
436
+ return result
437
+ }
438
+ let result
439
+ try {
440
+ result = await vault.detach(b)
441
+ } finally {
442
+ fs.promises.rename = realRename
443
+ }
444
+
445
+ assert.strictEqual(result.status, 'stale')
446
+ assert.deepStrictEqual(await fs.promises.readFile(b), replacement)
447
+ assert.strictEqual(vault.registry.excluded.has(b), false)
448
+ assert.strictEqual(fs.existsSync(b + Vault.TMP_SUFFIX), false)
449
+ })
450
+
451
+ test('detach stays registered when metadata fails after the atomic replacement', async () => {
452
+ const { home, vault } = await makeEnv()
453
+ const { content, b } = await makeSharedPair(home, vault)
454
+ const realRename = fs.promises.rename
455
+ const realLstat = fs.promises.lstat
456
+ let committed = false
457
+ let failedRead = false
458
+ fs.promises.rename = async (source, target) => {
459
+ const result = await realRename(source, target)
460
+ if (source === b + Vault.TMP_SUFFIX && target === b) committed = true
461
+ return result
462
+ }
463
+ fs.promises.lstat = async (target, options) => {
464
+ if (committed && !failedRead && path.resolve(String(target)) === b) {
465
+ failedRead = true
466
+ const error = new Error('temporary post-rename metadata failure')
467
+ error.code = 'EIO'
468
+ throw error
469
+ }
470
+ return realLstat(target, options)
471
+ }
472
+ let result
473
+ try {
474
+ result = await vault.detach(b)
475
+ } finally {
476
+ fs.promises.rename = realRename
477
+ fs.promises.lstat = realLstat
478
+ }
479
+
480
+ assert.strictEqual(failedRead, true)
481
+ assert.strictEqual(result.status, 'detached')
482
+ assert.deepStrictEqual(await fs.promises.readFile(b), content)
83
483
  assert.strictEqual((await fs.promises.stat(b)).nlink, 1)
484
+ assert.strictEqual(vault.registry.links.has(b), false)
485
+ assert.strictEqual(vault.registry.duplicates.get(b).size, content.length)
486
+ assert.strictEqual(vault.registry.duplicates.get(b).unverified, true)
487
+ assert.strictEqual(vault.registry.scanIndex.has(b), false)
488
+ assert.strictEqual(vault.registry.duplicates.get(b).ctime, undefined)
489
+ assert.strictEqual(vault.registry.excluded.has(b), false)
84
490
  })
85
491
 
86
- test('detach on a pending duplicate just ignores it', async () => {
492
+ test('undo never overwrites a file replaced while bytes are being copied out', async () => {
493
+ const { home, vault } = await makeEnv()
494
+ const { b } = await makeSharedPair(home, vault)
495
+ const replacement = Buffer.from('newer-writer-content')
496
+ const realLstat = fs.promises.lstat
497
+ let replaced = false
498
+ fs.promises.lstat = async (target, options) => {
499
+ if (!replaced && path.resolve(String(target)) === b + Vault.TMP_SUFFIX) {
500
+ replaced = true
501
+ const writerPath = b + '.writer-replacement'
502
+ await fs.promises.writeFile(writerPath, replacement)
503
+ await fs.promises.rename(writerPath, b)
504
+ }
505
+ return realLstat(target, options)
506
+ }
507
+ let result
508
+ try {
509
+ result = await vault.undoBatch('batch-m.bin')
510
+ } finally {
511
+ fs.promises.lstat = realLstat
512
+ }
513
+
514
+ assert.strictEqual(result.undone, 0)
515
+ assert.strictEqual(result.failed, 1)
516
+ assert.deepStrictEqual(await fs.promises.readFile(b), replacement)
517
+ assert.strictEqual(fs.existsSync(b + Vault.TMP_SUFFIX), false)
518
+ })
519
+
520
+ test('detach and undo use the current owning app even with a stale broad source id', async () => {
521
+ const { home, vault } = await makeEnv()
522
+ const { b } = await makeSharedPair(home, vault)
523
+ vault.registry.links.get(b).source_id = 'pinokio'
524
+ vault.kernel.api = {
525
+ running: { custom: true },
526
+ running_paths: { custom: path.resolve(home, 'api', 'appB', 'start.js') }
527
+ }
528
+
529
+ assert.strictEqual((await vault.detach(b)).status, 'locked')
530
+ const undone = await vault.undoBatch('batch-m.bin')
531
+ assert.strictEqual(undone.undone, 0)
532
+ assert.strictEqual(undone.failed, 1)
533
+ assert.ok((await fs.promises.stat(b)).nlink > 1)
534
+ })
535
+
536
+ test('detach and undo never follow a parent-directory link outside the source', {
537
+ skip: process.platform === 'win32'
538
+ }, async () => {
539
+ const { home, vault } = await makeEnv()
540
+ const { content, b } = await makeSharedPair(home, vault, 'nested/m.bin')
541
+ const outsideParent = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-detach-outside-'))
542
+ homes.push(outsideParent)
543
+ const moved = path.resolve(outsideParent, 'moved')
544
+ const nested = path.dirname(b)
545
+ await fs.promises.rename(nested, moved)
546
+ await fs.promises.symlink(moved, nested)
547
+ const before = await fs.promises.stat(b)
548
+
549
+ assert.strictEqual((await vault.detach(b)).status, 'stale')
550
+ const undone = await vault.undoBatch('batch-nested/m.bin')
551
+
552
+ assert.strictEqual(undone.undone, 0)
553
+ assert.strictEqual(undone.failed, 1)
554
+ assert.deepStrictEqual(await fs.promises.readFile(b), content)
555
+ const after = await fs.promises.stat(b)
556
+ assert.strictEqual(after.dev, before.dev)
557
+ assert.strictEqual(after.ino, before.ino)
558
+ assert.strictEqual(vault.registry.excluded.has(b), false)
559
+ })
560
+
561
+ test('Skip records a pending duplicate as kept separate', async () => {
87
562
  const { home, vault } = await makeEnv()
88
563
  const content = crypto.randomBytes(4096)
89
564
  const hash = sha256(content)
@@ -92,27 +567,191 @@ describe('vault dashboard backend (phase 4)', () => {
92
567
  const b = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
93
568
  vault.registry.duplicates.set(b, { hash, size: content.length, app: 'appB' })
94
569
 
95
- const result = await vault.detach(b)
570
+ const result = await vault.perform('skip', { path: b })
96
571
  assert.strictEqual(result.status, 'ignored')
97
572
  assert.strictEqual(vault.registry.duplicates.has(b), false)
98
573
  assert.ok(vault.registry.excluded.has(b))
99
574
  assert.strictEqual(vault.registry.excluded.get(b).size, content.length)
575
+ assert.ok((await vault.registry.readEvents()).some((event) => event.kind === 'skip' && event.path === b))
100
576
  })
101
577
 
102
578
  test('status(): disk figures, scan metadata, excluded list', async () => {
103
579
  const { home, vault } = await makeEnv()
104
580
  const { content, hash } = await makeSharedPair(home, vault)
105
- await vault.detach(path.resolve(home, 'api', 'appB', 'm.bin'))
581
+ const detached = path.resolve(home, 'api', 'appB', 'm.bin')
582
+ await vault.detach(detached)
583
+ await vault.skip(detached)
106
584
  await vault.sweeper.scan()
107
585
  const status = await vault.status()
108
586
  assert.strictEqual(status.enabled, true)
109
- assert.strictEqual(status.bytes_on_disk, content.length)
587
+ assert.strictEqual(status.bytes_on_disk, content.length * 2)
110
588
  assert.ok(status.last_scan && status.last_scan.files > 0)
111
589
  assert.strictEqual(status.excluded.length, 1)
112
590
  assert.strictEqual(status.excluded[0].size, content.length)
113
591
  assert.strictEqual(status.blobs.length, 1)
114
592
  assert.deepStrictEqual(status.blobs[0].apps, ['appA'])
115
593
  assert.ok(status.scan && status.scan.active === false)
594
+
595
+ await fs.promises.rm(path.resolve(home, 'api', 'appB'), { recursive: true })
596
+ await vault.refreshSources()
597
+ const afterSourceRemoval = await vault.status()
598
+ assert.strictEqual(afterSourceRemoval.excluded.length, 1, 'user-owned exclusions survive source removal')
599
+ assert.strictEqual(afterSourceRemoval.excluded[0].size, content.length)
600
+ })
601
+
602
+ test('an overlapping status response keeps the sequence of its older snapshot', async () => {
603
+ const { home, vault } = await makeEnv()
604
+ const { b } = await makeSharedPair(home, vault, 'status-sequence.bin')
605
+ const realReadEvents = vault.registry.readEvents.bind(vault.registry)
606
+ let releaseStatus
607
+ let statusWaiting
608
+ const gate = new Promise((resolve) => { releaseStatus = resolve })
609
+ const waiting = new Promise((resolve) => { statusWaiting = resolve })
610
+ vault.registry.readEvents = async () => {
611
+ statusWaiting()
612
+ await gate
613
+ return realReadEvents()
614
+ }
615
+
616
+ const sequenceBefore = vault.actionSequence
617
+ const pendingStatus = vault.status()
618
+ await waiting
619
+ try {
620
+ const detached = await vault.perform('detach', { path: b })
621
+ assert.strictEqual(detached.status, 'detached')
622
+ } finally {
623
+ releaseStatus()
624
+ vault.registry.readEvents = realReadEvents
625
+ }
626
+ const stale = await pendingStatus
627
+
628
+ assert.strictEqual(stale.action_sequence, sequenceBefore)
629
+ assert.ok(stale.action_sequence < vault.actionSequence)
630
+ assert.ok(stale.blobs.some((blob) => blob.names.some((name) => name.path === b)))
631
+ })
632
+
633
+ test('tracked storage metrics include physical pending duplicates', async () => {
634
+ const { home, vault } = await makeEnv()
635
+ const content = crypto.randomBytes(4096)
636
+ const hash = sha256(content)
637
+ const original = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
638
+ await vault.adopt(original, hash, { app: 'appA' })
639
+ await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
640
+ await vault.sweeper.scan()
641
+
642
+ const status = await vault.status()
643
+ assert.strictEqual(status.pending_bytes, content.length)
644
+ assert.strictEqual(status.bytes_on_disk, content.length * 2)
645
+ assert.strictEqual(status.bytes_without_sharing, content.length * 2)
646
+ assert.strictEqual(status.saved_by_sharing, 0)
647
+ })
648
+
649
+ test('app status exposes only that app while retaining its matching locations', async () => {
650
+ const { home, vault } = await makeEnv()
651
+ const content = crypto.randomBytes(4096)
652
+ const appAFile = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
653
+ const appBFile = await writeFile(path.resolve(home, 'api', 'appB', 'model.bin'), content)
654
+ await vault.sweeper.scan()
655
+ const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
656
+
657
+ const status = await vault.status(appB.id)
658
+
659
+ assert.strictEqual(status.scope_id, appB.id)
660
+ assert.deepStrictEqual(status.sources.map((source) => source.id), [appB.id])
661
+ assert.strictEqual(status.sources[0].parent_id, null)
662
+ assert.deepStrictEqual(status.duplicates.map((item) => item.path), [appBFile])
663
+ assert.strictEqual(status.blobs.length, 1)
664
+ assert.deepStrictEqual(new Set(status.blobs[0].names.map((name) => name.path)),
665
+ new Set([appAFile]))
666
+ assert.strictEqual(status.pending_bytes, content.length)
667
+ assert.strictEqual(status.bytes_without_sharing, content.length)
668
+ assert.strictEqual(status.bytes_on_disk, content.length)
669
+ assert.ok(status.last_scan && status.last_scan.files === 1)
670
+
671
+ const converted = await vault.perform('deduplicate', {
672
+ scope_id: appB.id,
673
+ paths: [appBFile]
674
+ })
675
+ assert.strictEqual(converted.converted, 1)
676
+ const sharedStatus = await vault.status(appB.id)
677
+ assert.strictEqual(sharedStatus.bytes_on_disk, content.length / 2)
678
+ })
679
+
680
+ test('app effective usage divides shared files across locations and fully counts other files', async () => {
681
+ const { home, vault } = await makeEnv()
682
+ const shared = crypto.randomBytes(4096)
683
+ const small = crypto.randomBytes(512)
684
+ const hash = sha256(shared)
685
+ const appAFile = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), shared)
686
+ const appBFile = await writeFile(path.resolve(home, 'api', 'appB', 'model.bin'), shared)
687
+ const appCFile = await writeFile(path.resolve(home, 'api', 'appC', 'model.bin'), shared)
688
+ await writeFile(path.resolve(home, 'api', 'appA', 'config.bin'), small)
689
+ await vault.sweeper.scan()
690
+ const sources = Object.fromEntries(vault.sources()
691
+ .filter((source) => source.kind === 'app')
692
+ .map((source) => [source.app, source]))
693
+
694
+ assert.strictEqual((await vault.convert(appBFile, hash, {
695
+ app: 'appB', source_id: sources.appB.id
696
+ })).status, 'converted')
697
+ assert.strictEqual((await vault.convert(appCFile, hash, {
698
+ app: 'appC', source_id: sources.appC.id
699
+ })).status, 'converted')
700
+
701
+ const status = await vault.status(sources.appA.id)
702
+ assert.strictEqual(status.last_scan.bytes_total, shared.length + small.length)
703
+ assert.strictEqual(status.bytes_without_sharing, shared.length + small.length)
704
+ assert.ok(Math.abs(status.bytes_on_disk - (small.length + (shared.length / 3))) < 1e-9)
705
+ assert.strictEqual((await fs.promises.stat(appAFile)).nlink, 4, 'three locations plus the hidden store name')
706
+ })
707
+
708
+ test('activity exposes source-relative paths instead of bare filenames', async () => {
709
+ const { home, vault } = await makeEnv()
710
+ const content = crypto.randomBytes(4096)
711
+ const hash = sha256(content)
712
+ const first = await writeFile(path.resolve(home, 'api', 'appA', 'models', 'm.bin'), content)
713
+ const second = await writeFile(path.resolve(home, 'api', 'appB', 'models', 'm.bin'), content)
714
+ await vault.refreshSources()
715
+ const appA = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
716
+ const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
717
+ await vault.adopt(first, hash, { app: 'appA', source_id: appA.id })
718
+ await vault.convert(second, hash, { app: 'appB', source_id: appB.id, batch_id: 'activity-path' })
719
+
720
+ const status = await vault.status()
721
+ const conversion = status.events.find((event) => event.kind === 'convert')
722
+ assert.strictEqual(conversion.source_label, 'appB')
723
+ assert.strictEqual(conversion.relative_path, 'models/m.bin')
724
+
725
+ const vaultPage = await vaultPageSource()
726
+ assert.match(vaultPage, /event\.relative_path \|\| event\.path/)
727
+ assert.doesNotMatch(vaultPage, /event\.path \? basename\(event\.path\)/)
728
+ })
729
+
730
+ test('activity keeps its source-relative context after a source disappears', async () => {
731
+ const { home, vault } = await makeEnv()
732
+ const file = await writeFile(path.resolve(home, 'api', 'appA', 'models', 'm.bin'), crypto.randomBytes(4096))
733
+ await vault.refreshSources()
734
+ const source = vault.sources().find((item) => item.kind === 'app' && item.app === 'appA')
735
+ await vault.recordEvent({ kind: 'found', path: file, source_id: source.id, size: 4096 })
736
+ await fs.promises.rm(path.resolve(home, 'api', 'appA'), { recursive: true, force: true })
737
+ await vault.refreshSources()
738
+
739
+ const status = await vault.status()
740
+ const event = status.events.find((item) => item.kind === 'found')
741
+ assert.strictEqual(event.source_label, 'appA')
742
+ assert.strictEqual(event.relative_path, 'models/m.bin')
743
+ })
744
+
745
+ test('activity exposes the complete retained bounded event window', async () => {
746
+ const { vault } = await makeEnv()
747
+ for (let index = 0; index < 110; index += 1) {
748
+ await vault.registry.appendEvent({ kind: 'found', index })
749
+ }
750
+
751
+ const status = await vault.status()
752
+ assert.strictEqual(status.events.length, 110)
753
+ assert.strictEqual(status.events[0].index, 109)
754
+ assert.strictEqual(status.events[109].index, 0)
116
755
  })
117
756
 
118
757
  test('reclaimAll frees every orphan and nothing else', async () => {
@@ -132,18 +771,158 @@ describe('vault dashboard backend (phase 4)', () => {
132
771
  assert.strictEqual(vault.registry.blobs.has(sha256(drop)), false)
133
772
  })
134
773
 
774
+ test('reclaimAll records earlier deletions if a later reclaim fails', async () => {
775
+ const { home, vault } = await makeEnv()
776
+ const first = crypto.randomBytes(4096)
777
+ const second = crypto.randomBytes(4096)
778
+ const firstHash = sha256(first)
779
+ const secondHash = sha256(second)
780
+ const firstFile = await writeFile(path.resolve(home, 'api', 'appA', 'first.bin'), first)
781
+ const secondFile = await writeFile(path.resolve(home, 'api', 'appA', 'second.bin'), second)
782
+ await vault.adopt(firstFile, firstHash, { app: 'appA' })
783
+ await vault.adopt(secondFile, secondHash, { app: 'appA' })
784
+ await fs.promises.unlink(firstFile)
785
+ await fs.promises.unlink(secondFile)
786
+ await vault.verify()
787
+ const realReclaim = vault.reclaim.bind(vault)
788
+ let calls = 0
789
+ vault.reclaim = async (...args) => {
790
+ calls += 1
791
+ if (calls === 2) {
792
+ const error = new Error('transient unlink failure')
793
+ error.code = 'EIO'
794
+ throw error
795
+ }
796
+ return realReclaim(...args)
797
+ }
798
+ try {
799
+ await assert.rejects(vault.reclaimAll(), (error) => error.code === 'EIO')
800
+ } finally {
801
+ vault.reclaim = realReclaim
802
+ }
803
+
804
+ assert.strictEqual(fs.existsSync(vault.storePathFor(firstHash)), false)
805
+ assert.strictEqual(vault.registry.blobs.has(firstHash), false)
806
+ assert.strictEqual(fs.existsSync(vault.storePathFor(secondHash)), true)
807
+ assert.strictEqual(vault.registry.blobs.has(secondHash), true)
808
+ })
809
+
810
+ test('status fails closed on a non-missing store metadata error', async () => {
811
+ const { home, vault } = await makeEnv()
812
+ const content = crypto.randomBytes(4096)
813
+ const hash = sha256(content)
814
+ const file = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
815
+ await vault.adopt(file, hash, { app: 'appA' })
816
+ const storePath = vault.storePathFor(hash)
817
+ const realStat = fs.promises.lstat
818
+ fs.promises.lstat = async (target, options) => {
819
+ if (path.resolve(target) === storePath) {
820
+ const error = new Error('transient metadata failure')
821
+ error.code = 'EIO'
822
+ throw error
823
+ }
824
+ return realStat(target, options)
825
+ }
826
+ try {
827
+ await assert.rejects(vault.status(), (error) => error.code === 'EIO')
828
+ } finally {
829
+ fs.promises.lstat = realStat
830
+ }
831
+
832
+ assert.strictEqual(vault.registry.blobs.has(hash), true)
833
+ assert.strictEqual(vault.registry.links.has(file), true)
834
+ })
835
+
836
+ test('queued and failed scans remain visible to progress polling', async () => {
837
+ const { vault } = await makeEnv()
838
+ let release
839
+ const blocker = vault.runExclusive(() => new Promise((resolve) => { release = resolve }))
840
+ await new Promise((resolve) => setImmediate(resolve))
841
+ assert.strictEqual(vault.startScan().started, true)
842
+ const queuedScan = vault.scanPromise
843
+ const queuedStatus = vault.scanStatus()
844
+ assert.strictEqual(queuedStatus.pending, true)
845
+ assert.strictEqual(queuedStatus.active, false)
846
+ assert.strictEqual(queuedStatus.phase, 'queued')
847
+ assert.strictEqual(queuedStatus.files, 0)
848
+ release()
849
+ await blocker
850
+ await queuedScan
851
+ assert.strictEqual(vault.scanStatus().pending, false)
852
+
853
+ const realScan = vault.sweeper.scan.bind(vault.sweeper)
854
+ vault.sweeper.scan = async () => {
855
+ throw new Error('scan read failed')
856
+ }
857
+ assert.strictEqual(vault.startScan().started, true)
858
+ const failedScan = vault.scanPromise
859
+ await assert.rejects(failedScan, /scan read failed/)
860
+ vault.sweeper.scan = realScan
861
+ assert.strictEqual(vault.scanStatus().pending, false)
862
+ assert.strictEqual(vault.scanStatus().error, 'scan read failed')
863
+
864
+ const source = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'public', 'vault.js'), 'utf8')
865
+ assert.match(source, /scan\.pending \|\| scan\.active/)
866
+ assert.match(source, /\|\| state\.scanRequested\) delay = 1500/)
867
+ })
868
+
869
+ test('partial scans remain visible with their inaccessible paths and do not masquerade as complete', async () => {
870
+ const vaultPage = await vaultPageSource()
871
+ assert.match(vaultPage, /scan_incomplete:\s*"Scan incomplete"/)
872
+ assert.match(vaultPage, /const incomplete = contextualScan && !scanning && !!data\.incomplete/)
873
+ assert.match(vaultPage, /id="btn-scan-problems" aria-expanded=/)
874
+ assert.match(vaultPage, /id="vault-result-paths"/)
875
+ assert.match(vaultPage, /paths\.hidden = !state\.scanProblemsOpen/)
876
+ assert.match(vaultPage, /info\.inaccessiblePaths\.map\(\(filePath\) =>/)
877
+ assert.match(vaultPage, /class="vault-result-path"/)
878
+ assert.match(vaultPage, /\.vault-result\.incomplete \{[\s\S]*?flex:\s*0 0 auto/)
879
+ assert.match(vaultPage, /\.vault-result-path span \{[\s\S]*?overflow-wrap:\s*anywhere/)
880
+ assert.match(vaultPage, /\.vault-result-paths \{[\s\S]*?max-height:\s*144px[\s\S]*?overflow-y:\s*auto/)
881
+ assert.doesNotMatch(vaultPage, /inaccessiblePaths\.map\(esc\)\.join\(" · "\)/)
882
+ })
883
+
884
+ test('progress remains active and below completion while scan verification runs', async () => {
885
+ const { vault } = await makeEnv()
886
+ const realVerify = vault.verify.bind(vault)
887
+ let entered
888
+ let release
889
+ const verifyEntered = new Promise((resolve) => { entered = resolve })
890
+ const verifyRelease = new Promise((resolve) => { release = resolve })
891
+ vault.verify = async () => {
892
+ entered()
893
+ await verifyRelease
894
+ return realVerify()
895
+ }
896
+ assert.strictEqual(vault.startScan().started, true)
897
+ const scan = vault.scanPromise
898
+ try {
899
+ await verifyEntered
900
+ const status = vault.progressStatus().scan
901
+ assert.strictEqual(status.active, true)
902
+ assert.strictEqual(status.phase, 'verifying')
903
+ const source = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'public', 'vault.js'), 'utf8')
904
+ assert.match(source, /verifying\s*\?\s*0\.99/)
905
+ } finally {
906
+ release()
907
+ await scan
908
+ vault.verify = realVerify
909
+ }
910
+ })
911
+
135
912
  test('item 16: vocabulary lint — vault page copy avoids forbidden terms', async () => {
136
- const forbidden = /hard.?link|junction|symlink|inode|\bblob\b|\bstore\b|\bdedupe\b/i
137
- const vaultPage = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'vault.ejs'), 'utf8')
913
+ const forbidden = /hard.?link|junction|symlink|inode|\bblob\b|\bstore\b|\bdedupe\b|\bvault\b/i
914
+ const vaultPage = await vaultPageSource()
138
915
  const copyBlock = vaultPage.match(/const COPY = \{[\s\S]*?\n\}/)
139
916
  assert.ok(copyBlock, 'vault.ejs must keep user copy in a COPY object')
140
917
  for (const m of copyBlock[0].matchAll(/:\s*"([^"]*)"/g)) {
141
918
  assert.ok(!forbidden.test(m[1]), `forbidden term in vault page copy: "${m[1]}"`)
142
919
  }
920
+ const outsideCopy = vaultPage.replace(copyBlock[0], '')
921
+ assert.doesNotMatch(outsideCopy, /Add external folder|folder picker could not be opened|Couldn’t load Save space status|Search in \$\{/)
143
922
  })
144
923
 
145
924
  test('duplicate matches show complete filesystem paths without ellipsis', async () => {
146
- const vaultPage = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'vault.ejs'), 'utf8')
925
+ const vaultPage = await vaultPageSource()
147
926
  assert.match(vaultPage, /class="vault-match-path">\$\{esc\(match\.path\)\}/)
148
927
  const style = vaultPage.match(/\.vault-match-path \{[\s\S]*?\n\}/)
149
928
  assert.ok(style, 'match path must have dedicated wrapping styles')
@@ -152,38 +931,46 @@ describe('vault dashboard backend (phase 4)', () => {
152
931
  assert.doesNotMatch(style[0], /text-overflow:\s*ellipsis/)
153
932
  })
154
933
 
155
- test('copy-mode tracking never claims physical sharing savings', async () => {
156
- const { home, vault } = await makeEnv()
157
- const content = crypto.randomBytes(4096)
158
- const hash = sha256(content)
159
- const first = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
160
- const second = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
161
- const firstStat = await fs.promises.stat(first)
162
- const secondStat = await fs.promises.stat(second)
163
- vault.registry.addBlob(hash, { size: content.length })
164
- vault.registry.addLink(first, { hash, app: 'appA', dev: firstStat.dev, ino: firstStat.ino, mode: 'copy' })
165
- vault.registry.addLink(second, { hash, app: 'appB', dev: secondStat.dev, ino: secondStat.ino, mode: 'copy' })
166
-
167
- const status = await vault.status()
168
- assert.strictEqual(status.bytes_on_disk, content.length * 2)
169
- assert.strictEqual(status.saved_by_sharing, 0)
170
- })
171
-
172
934
  test('repair is advanced, metrics distinguish detected and explicit savings, and refresh retries', async () => {
173
- const vaultPage = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'vault.ejs'), 'utf8')
174
- assert.match(vaultPage, /already_shared:\s*"Already shared"/)
175
- assert.match(vaultPage, /saved_by_vault:\s*"Saved by Vault"/)
176
- assert.match(vaultPage, /repair_index:\s*"Repair Vault index"/)
935
+ const vaultPage = await vaultPageSource()
936
+ assert.match(vaultPage, /disk_space_saved:\s*"of disk space saved"/)
937
+ assert.match(vaultPage, /before_help:\s*"Estimated space if every app stored its own copy\."/)
938
+ assert.match(vaultPage, /no_more_matches:\s*"No more matches in files \{size\} and larger"/)
939
+ assert.match(vaultPage, /more_can_be_saved:\s*"\{size\} more can be saved"/)
940
+ assert.match(vaultPage, /available_to_save:\s*"\{size\} available to save"/)
941
+ assert.match(vaultPage, /Number\.isFinite\(data\.saved_by_sharing\)/)
942
+ assert.match(vaultPage, /fmt\(savedBytes\)/)
943
+ assert.match(vaultPage, /Number\.isFinite\(data\.bytes_without_sharing\)/)
944
+ assert.match(vaultPage, /Number\.isFinite\(data\.bytes_on_disk\)/)
945
+ assert.match(vaultPage, /Number\(data\.pending_bytes\)/)
946
+ assert.match(vaultPage, /id="btn-review-metric"/)
947
+ assert.match(vaultPage, /\.vault-button\.review-primary \{[\s\S]*?background:\s*var\(--task-accent-contrast\)[\s\S]*?color:\s*#101828/)
948
+ assert.match(vaultPage, /class="vault-button primary review-primary" type="button" id="btn-review-result"/)
949
+ assert.match(vaultPage, /class="vault-button primary" type="button" data-deduplicate-scope=/)
950
+ assert.match(vaultPage, /id='vault-storage-details'/)
951
+ assert.doesNotMatch(vaultPage, /lifetime_bytes_saved/)
952
+ assert.match(vaultPage, /repair_index:\s*"Repair index"/)
177
953
  assert.match(vaultPage, /<details class='vault-advanced'/)
178
954
  assert.doesNotMatch(vaultPage, /id='btn-rebuild'/)
179
955
  const refreshBlock = vaultPage.match(/const refresh = async \(\) => \{[\s\S]*?\n\}/)
180
956
  assert.ok(refreshBlock)
181
957
  assert.match(refreshBlock[0], /finally/)
958
+ assert.match(refreshBlock[0], /requestId !== state\.refreshRequestId/)
182
959
  assert.doesNotMatch(vaultPage, /priorScanning/)
960
+ assert.match(vaultPage, /const deduplicateFeedback = \(result\) =>/)
961
+ assert.match(vaultPage, /\(result\.locked \|\| 0\) \+ \(result\.incompatible \|\| 0\) \+ \(result\.unavailable \|\| 0\) \+ \(result\.failed \|\| 0\)/)
962
+ assert.match(refreshBlock[0], /delay == null \? null : setTimeout/)
963
+ assert.match(vaultPage, /if \(!response\.ok\)/)
964
+ assert.match(vaultPage, /item\.unavailable_reason === "different_disk" \? COPY\.different_disk : COPY\.sharing_unavailable/)
965
+ assert.match(vaultPage, /role='status' aria-live='polite'/)
966
+ assert.match(vaultPage, /if \(state\.view === "activity"\) return activityItems\(\)/)
967
+ assert.match(vaultPage, /if \(state\.view === "reclaimable"\) return state\.data\.blobs\.filter/)
968
+ assert.match(vaultPage, /hashTotal - \(scan\.queued \|\| 0\)/)
969
+ assert.doesNotMatch(vaultPage, /item\.saved/)
183
970
  })
184
971
 
185
972
  test('vault uses compact desktop density without truncating duplicate matches', async () => {
186
- const vaultPage = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'vault.ejs'), 'utf8')
973
+ const vaultPage = await vaultPageSource()
187
974
  assert.match(vaultPage, /--vault-control-height:\s*28px/)
188
975
  assert.match(vaultPage, /--vault-row-height:\s*44px/)
189
976
  assert.match(vaultPage, /--vault-tree-row:\s*26px/)
@@ -194,26 +981,55 @@ describe('vault dashboard backend (phase 4)', () => {
194
981
  assert.match(vaultPage, /\.vault-match-path \{[\s\S]*?overflow-wrap:\s*anywhere/)
195
982
  assert.doesNotMatch(vaultPage, /<header class='task-shell-header'>/)
196
983
  assert.doesNotMatch(vaultPage, /id='vault-(?:title|subtitle)'/)
197
- assert.match(vaultPage, /\.vault-overview \{[\s\S]*?padding:\s*7px var\(--vault-inline\)/)
198
- assert.match(vaultPage, /\.vault-metric \{[\s\S]*?border:\s*0;[\s\S]*?border-left:\s*1px solid var\(--task-border\)/)
199
- assert.match(vaultPage, /button\.vault-metric \{[\s\S]*?font:\s*inherit/)
984
+ assert.match(vaultPage, /body\[data-vault-mode="global"\] \.vault-overview,[\s\S]*?min-height:\s*104px/)
985
+ assert.match(vaultPage, /\.vault-metrics\.summary \{[\s\S]*?grid-template-columns:\s*minmax\(430px, 620px\) minmax\(230px, 1fr\)/)
986
+ assert.match(vaultPage, /\.vault-compare-row \{[\s\S]*?grid-template-columns:\s*54px minmax\(180px, 1fr\) 68px/)
987
+ assert.match(vaultPage, /\.vault-compare-fill\.after \{[\s\S]*?width:\s*var\(--vault-after-ratio\)/)
988
+ assert.match(vaultPage, /class="vault-compare-info" type="button" aria-describedby="\$\{helpId\}"/)
989
+ assert.match(vaultPage, /document\.body\.appendChild\(tooltip\)/)
990
+ assert.match(vaultPage, /\.vault-compare-tooltip \{[\s\S]*?position:\s*fixed/)
991
+ assert.doesNotMatch(vaultPage, /vault-compare-info[^}]*vault-compare-tooltip/)
992
+ assert.doesNotMatch(vaultPage, /\.vault-metric \{/)
993
+ assert.match(vaultPage, /\.vault-table\.inventory \.vault-columns,[\s\S]*?grid-template-columns:\s*minmax\(260px, 1\.7fr\) minmax\(72px, \.4fr\) minmax\(240px, 1fr\)/)
994
+ assert.match(vaultPage, /\.vault-table\.activity \.vault-columns,[\s\S]*?grid-template-columns:[^;]+;/)
995
+ assert.match(vaultPage, /\.vault-table\.flat \.vault-columns,[\s\S]*?grid-template-columns:[^;]+;/)
996
+ assert.match(vaultPage, /\.vault-display-mode \{[\s\S]*?height:\s*30px/)
997
+ assert.match(vaultPage, /\.vault-flat-location \{[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?white-space:\s*normal/)
998
+ assert.match(vaultPage, /\.vault-sharing-switch::before \{[\s\S]*?width:\s*32px;[\s\S]*?height:\s*18px;/)
999
+ assert.match(vaultPage, /\.vault-sharing-switch\.on \.vault-sharing-thumb \{[\s\S]*?transform:\s*translateX\(14px\)/)
1000
+ assert.match(vaultPage, /\.vault-sharing-switch:focus-visible/)
200
1001
  assert.match(vaultPage, /id='btn-add-source'/)
201
1002
  assert.match(vaultPage, /<script src="\/Socket\.js"><\/script>/)
202
1003
  assert.match(vaultPage, /action:\s*"add_source"/)
1004
+ assert.match(vaultPage, /data-remove-source=/)
1005
+ assert.match(vaultPage, /action:\s*"remove_source"/)
203
1006
  assert.match(vaultPage, /identical_contents_at:\s*"Identical contents at"/)
204
1007
  assert.doesNotMatch(vaultPage, /Matching locations/)
205
1008
  assert.match(vaultPage, /scan_waiting:\s*"Waiting for scan results"/)
206
- assert.match(vaultPage, /view === "all" && !scanning \? `<button class="vault-button" type="button" id="btn-empty-scan"/)
1009
+ assert.match(vaultPage, /view === "all" && !activeScan \? `<button class="vault-button" type="button" id="btn-empty-scan"/)
207
1010
  assert.match(vaultPage, /class="vault-progress-track"/)
208
1011
  assert.match(vaultPage, /role="progressbar"/)
209
- assert.match(vaultPage, /scan\.walk_duration_ms == null/)
1012
+ assert.match(vaultPage, /const counting = scanPhase === "counting"/)
210
1013
  assert.match(vaultPage, /const hashTotal = scan\.hash_total/)
211
- assert.match(vaultPage, /const hasEstimate = Number\.isFinite\(scan\.estimated_files\)/)
212
- assert.match(vaultPage, /scan_estimate_help:\s*"Estimated from your last completed scan"/)
213
- assert.match(vaultPage, /\$\{estimated \? "~" : ""\}\$\{progressValue\}%/)
1014
+ assert.match(vaultPage, /const totalFiles = Number\.isFinite\(scan\.total_files\)/)
1015
+ assert.match(vaultPage, /scan_counting_help:\s*"The first scan cannot know its total until this pass finishes"/)
1016
+ assert.match(vaultPage, /scan_count_estimate_help:\s*"Estimated from the exact file total and timing of the last completed scan"/)
1017
+ assert.match(vaultPage, /const countEstimate = Number\.isFinite\(scan\.count_estimate_files\)/)
1018
+ assert.match(vaultPage, /const countRatio = countEstimate === null/)
1019
+ assert.match(vaultPage, /counting\s*\? countRatio \* countWeight/)
1020
+ assert.match(vaultPage, /pinokio:vault:reviewed-scan/)
1021
+ assert.match(vaultPage, /completed \|\| incomplete \|\| \(!state\.scanResult && unreviewed\)/)
1022
+ assert.match(vaultPage, /state\.data\.undo_batches/)
1023
+ assert.match(vaultPage, /data-undo="\$\{attr\(batch\.batch_id\)\}"/)
1024
+ assert.match(vaultPage, /lastAttempt\.hash_failures > 0/)
1025
+ assert.match(vaultPage, /scan_not_analyzed:\s*"could not be analyzed"/)
1026
+ assert.match(vaultPage, /percent = `~\$\{progressValue\}%`/)
1027
+ assert.match(vaultPage, /COPY\.scan_checked\.replace\("\{done\}", hashDone\)\.replace\("\{total\}", hashTotal\)/)
214
1028
  assert.match(vaultPage, /\.vault-progress-bar\.determinate \{[\s\S]*?transform:\s*scaleX/)
215
- assert.match(vaultPage, /\.vault-progress-bar\.indeterminate \{[\s\S]*?transform:\s*scaleX\(1\)[\s\S]*?animation:\s*vault-progress-pulse/)
216
- assert.doesNotMatch(vaultPage, /vault-progress-sweep/)
1029
+ assert.match(vaultPage, /\.vault-progress-bar\.indeterminate \{[\s\S]*?animation:\s*vault-progress-discovery/)
1030
+ assert.match(vaultPage, /COPY\.scan_files_checked\.replace\("\{done\}", scan\.files \|\| 0\)\.replace\("\{total\}", totalFiles\)/)
1031
+ assert.match(vaultPage, /if \(!scanState\.querySelector\("\.vault-progress-track"\)\)/)
1032
+ assert.doesNotMatch(vaultPage, /scanState\.innerHTML = `<i[^\n]*\$\{progress\}/)
217
1033
  assert.match(vaultPage, /body\.vault-page \.task-container \{[\s\S]*?overflow:\s*hidden/)
218
1034
  assert.match(vaultPage, /\.vault-explorer \{[\s\S]*?flex:\s*1 1 auto[\s\S]*?overflow:\s*hidden/)
219
1035
  assert.match(vaultPage, /\.vault-rail \{[\s\S]*?overflow-y:\s*auto[\s\S]*?overscroll-behavior:\s*contain/)
@@ -222,11 +1038,1166 @@ describe('vault dashboard backend (phase 4)', () => {
222
1038
  assert.match(vaultPage, /const toolbarSummary = \(visibleItems\) =>/)
223
1039
  assert.match(vaultPage, /id="vault-toolbar-summary"/)
224
1040
  assert.match(vaultPage, /\.vault-table-wrap \{[\s\S]*?overflow:\s*auto[\s\S]*?overscroll-behavior:\s*contain/)
1041
+ assert.match(vaultPage, /@media \(pointer:\s*coarse\) \{[\s\S]*?--vault-control-height:\s*44px/)
1042
+ assert.match(vaultPage, /aria-expanded=/)
225
1043
  assert.match(vaultPage, /#vault-locations \{[\s\S]*?flex-direction:\s*column[\s\S]*?justify-content:\s*flex-start[\s\S]*?gap:\s*0/)
226
1044
  assert.match(vaultPage, /\.vault-source-line\.depth-2 \{ padding-left:\s*24px; \}/)
227
1045
  assert.match(vaultPage, /vault-source-line depth-\$\{Math\.min\(depth, 2\)\} \$\{pathText \? "has-path" : ""\}/)
228
1046
  })
229
1047
 
1048
+ test('vault template keeps behavior and styling in dedicated assets', async () => {
1049
+ const template = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'vault.ejs'), 'utf8')
1050
+ assert.match(template, /<link href="\/vault\.css" rel="stylesheet"\/>/)
1051
+ assert.match(template, /<script src="\/vault\.js"><\/script>/)
1052
+ assert.doesNotMatch(template, /<style>/)
1053
+ assert.doesNotMatch(template, /<script>\s*const COPY/)
1054
+ })
1055
+
1056
+ test('app Save space reuses the Vault workspace in the retained app frame', async () => {
1057
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1058
+ const [appTemplate, globalTemplate, embeddedTemplate] = await Promise.all([
1059
+ fs.promises.readFile(path.resolve(views, 'app.ejs'), 'utf8'),
1060
+ fs.promises.readFile(path.resolve(views, 'vault.ejs'), 'utf8'),
1061
+ fs.promises.readFile(path.resolve(views, 'vault_app.ejs'), 'utf8')
1062
+ ])
1063
+ assert.match(appTemplate, /id='save-space-tab'[\s\S]*?target="app-vault"[\s\S]*?class="btn header-item frame-link"/)
1064
+ assert.match(appTemplate, /class="btn header-item frame-link"[^>]*data-tab-link-popover="false"/)
1065
+ assert.ok(appTemplate.indexOf("id='save-space-tab'") < appTemplate.indexOf('class="app-autolaunch"'))
1066
+ const foregroundSyncStart = appTemplate.indexOf('const foreground_frame =')
1067
+ const foregroundSyncEnd = appTemplate.indexOf('}, 1000)', foregroundSyncStart)
1068
+ const foregroundSync = appTemplate.slice(foregroundSyncStart, foregroundSyncEnd)
1069
+ assert.ok(foregroundSyncStart >= 0 && foregroundSyncEnd > foregroundSyncStart)
1070
+ assert.match(foregroundSync, /match\.classList\.add\("selected"\)/)
1071
+ assert.doesNotMatch(foregroundSync, /match\.click\(\)/)
1072
+ assert.strictEqual((foregroundSync.match(/selected_tab\.click\(\)/g) || []).length, 1)
1073
+ assert.match(globalTemplate, /include\('partials\/vault_workspace', \{ appMode: false \}\)/)
1074
+ assert.match(embeddedTemplate, /include\('partials\/vault_workspace', \{ appMode: true \}\)/)
1075
+
1076
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1077
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
1078
+ })
1079
+ assert.match(html, /data-vault-mode="app"/)
1080
+ assert.match(html, /data-vault-scope="app:appB"/)
1081
+ assert.match(html, /id='btn-scan'/)
1082
+ assert.match(html, /id='vault-explorer'/)
1083
+ assert.doesNotMatch(html, /id='btn-add-source'/)
1084
+ assert.doesNotMatch(html, /id='btn-repair'/)
1085
+ assert.doesNotMatch(html, /src="\/Socket\.js"/)
1086
+
1087
+ const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
1088
+ const routeStart = serverSource.indexOf('this.app.get("/vault/app/:name"')
1089
+ const routeEnd = serverSource.indexOf('this.app.post("/vault/action"', routeStart)
1090
+ const route = serverSource.slice(routeStart, routeEnd)
1091
+ assert.ok(routeStart >= 0 && routeEnd > routeStart)
1092
+ assert.match(route, /isSameOriginRequest/)
1093
+ assert.match(route, /item\.kind === "app" && item\.app === req\.params\.name && item\.available/)
1094
+ assert.match(route, /res\.render\("vault_app"/)
1095
+ const infoRoute = serverSource.slice(
1096
+ serverSource.indexOf('this.app.get("/info/dedup"'),
1097
+ serverSource.indexOf('this.app.get("/vault"')
1098
+ )
1099
+ assert.match(infoRoute, /req\.query\.scope_id/)
1100
+ assert.match(infoRoute, /vault\.status\(scopeId\)/)
1101
+ assert.match(infoRoute, /vault\.progressStatus\(scopeId\)/)
1102
+ })
1103
+
1104
+ test('fixed Save space navigation opts out of dynamic tab actions', async () => {
1105
+ const script = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'public', 'tab-link-popover.js'), 'utf8')
1106
+ const dom = new JSDOM(`
1107
+ <div class="appcanvas"><aside><div class="menu-container">
1108
+ <a id="save-space" class="frame-link" href="/vault/app/example" data-tab-link-popover="false">Save space</a>
1109
+ <a id="dynamic-tab" class="frame-link" href="http://localhost:8000">Web UI</a>
1110
+ </div></aside></div>
1111
+ `, { url: 'http://localhost/app/example', runScripts: 'dangerously' })
1112
+ dom.window.eval(script)
1113
+ dom.window.setupTabLinkHover()
1114
+
1115
+ assert.strictEqual(dom.window.document.querySelector('#save-space .tab-link-popover-trigger'), null)
1116
+ assert.ok(dom.window.document.querySelector('#dynamic-tab .tab-link-popover-trigger'))
1117
+ dom.window.close()
1118
+ })
1119
+
1120
+ test('Save space navigation promotes first use and then shows scoped status', async () => {
1121
+ const script = await fs.promises.readFile(
1122
+ path.resolve(__dirname, '..', 'server', 'public', 'vault-nav.js'), 'utf8')
1123
+ let payload = { any_scan: false, scanned: false, scanning: false, pending_bytes: 0 }
1124
+ const requests = []
1125
+ const settle = () => new Promise((resolve) => setImmediate(resolve))
1126
+ const response = async (url) => {
1127
+ requests.push(String(url))
1128
+ return { ok: true, json: async () => Object.assign({}, payload) }
1129
+ }
1130
+
1131
+ const globalDom = new JSDOM(`
1132
+ <a data-main-sidebar-vault-tab>
1133
+ <span data-main-sidebar-vault-status hidden></span>
1134
+ </a>
1135
+ `, { url: 'http://localhost/home', runScripts: 'dangerously' })
1136
+ globalDom.window.fetch = response
1137
+ globalDom.window.eval(script)
1138
+ globalDom.window.document.dispatchEvent(new globalDom.window.Event('DOMContentLoaded'))
1139
+ await settle()
1140
+ const globalTab = globalDom.window.document.querySelector('[data-main-sidebar-vault-tab]')
1141
+ const globalBadge = globalDom.window.document.querySelector('[data-main-sidebar-vault-status]')
1142
+ assert.strictEqual(globalTab.dataset.vaultState, 'new')
1143
+ assert.strictEqual(globalBadge.textContent, 'NEW')
1144
+ assert.strictEqual(globalBadge.hidden, false)
1145
+
1146
+ payload = { any_scan: true, scanned: false, scanning: false, pending_bytes: 0 }
1147
+ globalDom.window.document.dispatchEvent(new globalDom.window.CustomEvent('pinokio:vault-status-changed'))
1148
+ await settle()
1149
+ assert.strictEqual(globalTab.dataset.vaultState, 'not-scanned')
1150
+ assert.strictEqual(globalBadge.textContent, 'Not scanned')
1151
+
1152
+ payload = { any_scan: true, scanned: true, scanning: true, pending_bytes: 0 }
1153
+ globalDom.window.document.dispatchEvent(new globalDom.window.CustomEvent('pinokio:vault-status-changed'))
1154
+ await settle()
1155
+ assert.strictEqual(globalTab.dataset.vaultState, 'scanning')
1156
+ assert.strictEqual(globalBadge.textContent, 'Scanning')
1157
+
1158
+ payload = { any_scan: true, scanned: true, scanning: false, pending_bytes: 12e9 }
1159
+ globalDom.window.document.dispatchEvent(new globalDom.window.CustomEvent('pinokio:vault-status-changed'))
1160
+ await settle()
1161
+ assert.strictEqual(globalTab.dataset.vaultState, 'pending')
1162
+ assert.strictEqual(globalBadge.textContent, '12 GB')
1163
+ globalDom.window.close()
1164
+
1165
+ payload = { any_scan: false, scanned: false, scanning: false, pending_bytes: 0 }
1166
+ const appDom = new JSDOM(`
1167
+ <a data-app-vault-tab data-app-vault-name="demo app">
1168
+ <span data-app-vault-prompt hidden>Find duplicate files and use less disk</span>
1169
+ <span data-app-vault-status hidden></span>
1170
+ </a>
1171
+ `, { url: 'http://localhost/app/demo', runScripts: 'dangerously' })
1172
+ appDom.window.fetch = response
1173
+ appDom.window.eval(script)
1174
+ appDom.window.document.dispatchEvent(new appDom.window.Event('DOMContentLoaded'))
1175
+ await settle()
1176
+ const appTab = appDom.window.document.querySelector('[data-app-vault-tab]')
1177
+ const appPrompt = appDom.window.document.querySelector('[data-app-vault-prompt]')
1178
+ const appBadge = appDom.window.document.querySelector('[data-app-vault-status]')
1179
+ assert.strictEqual(requests.at(-1), '/info/dedup/nav?app=demo%20app')
1180
+ assert.strictEqual(appTab.dataset.vaultState, 'new')
1181
+ assert.strictEqual(appPrompt.hidden, false)
1182
+ assert.strictEqual(appBadge.textContent, 'NEW')
1183
+
1184
+ payload = { any_scan: true, scanned: false, scanning: false, pending_bytes: 0 }
1185
+ appDom.window.document.dispatchEvent(new appDom.window.CustomEvent('pinokio:vault-status-changed'))
1186
+ await settle()
1187
+ assert.strictEqual(appTab.dataset.vaultState, 'not-scanned')
1188
+ assert.strictEqual(appPrompt.hidden, true)
1189
+ assert.strictEqual(appBadge.textContent, 'Not scanned')
1190
+ appDom.window.close()
1191
+ })
1192
+
1193
+ test('navigation status endpoint is same-origin, memory-only, and non-initializing', async () => {
1194
+ const serverSource = await fs.promises.readFile(
1195
+ path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
1196
+ const routeStart = serverSource.indexOf('this.app.get("/info/dedup/nav"')
1197
+ const routeEnd = serverSource.indexOf('this.app.get("/info/dedup"', routeStart)
1198
+ const route = serverSource.slice(routeStart, routeEnd)
1199
+ assert.ok(routeStart >= 0 && routeEnd > routeStart)
1200
+ assert.match(route, /isSameOriginRequest/)
1201
+ assert.match(route, /res\.sendStatus\(404\)/)
1202
+ assert.match(route, /Cache-Control", "no-store"/)
1203
+ assert.match(route, /vault\.navigationStatusForApp\(appName\)/)
1204
+ assert.match(route, /vault\.navigationStatus\(\)/)
1205
+ assert.doesNotMatch(route, /ensureInitialized/)
1206
+
1207
+ const [sidebar, appTemplate, vaultScript] = await Promise.all([
1208
+ fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'partials', 'main_sidebar.ejs'), 'utf8'),
1209
+ fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'app.ejs'), 'utf8'),
1210
+ fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'public', 'vault.js'), 'utf8')
1211
+ ])
1212
+ assert.match(sidebar, /data-main-sidebar-vault-tab/)
1213
+ assert.match(sidebar, /src="\/vault-nav\.js"/)
1214
+ assert.match(appTemplate, /data-app-vault-tab/)
1215
+ assert.match(appTemplate, /Find duplicate files and use less disk/)
1216
+ assert.match(appTemplate, /src="\/vault-nav\.js" defer/)
1217
+ const promptRule = appTemplate.match(/#save-space-tab \.save-space-prompt \{[\s\S]*?\n\}/)
1218
+ assert.ok(promptRule)
1219
+ assert.match(promptRule[0], /white-space:\s*normal/)
1220
+ assert.match(promptRule[0], /overflow-wrap:\s*break-word/)
1221
+ assert.doesNotMatch(promptRule[0], /text-overflow:\s*ellipsis/)
1222
+ assert.match(vaultScript, /notifyNavigationStatus\(\)/)
1223
+ assert.match(vaultScript, /pinokio:vault-status-changed/)
1224
+ })
1225
+
1226
+ test('app Vault workspace loads scoped data without global-only controls', async () => {
1227
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1228
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1229
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
1230
+ })
1231
+ const dom = new JSDOM(html, { url: 'http://localhost/vault/app/appB', runScripts: 'dangerously' })
1232
+ const requests = []
1233
+ dom.window.fetch = async (url) => {
1234
+ requests.push(String(url))
1235
+ return {
1236
+ ok: true,
1237
+ json: async () => ({
1238
+ enabled: true,
1239
+ mode: 'link',
1240
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1241
+ last_scan: null,
1242
+ bytes_on_disk: null,
1243
+ pending_bytes: 0,
1244
+ activity_error: null,
1245
+ cloud_sync_warning: null,
1246
+ sources: [{
1247
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1248
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1249
+ }],
1250
+ blobs: [], duplicates: [], excluded: [], events: [], undo_batches: []
1251
+ })
1252
+ }
1253
+ }
1254
+ await runVaultScript(dom)
1255
+ await new Promise((resolve) => setTimeout(resolve, 25))
1256
+
1257
+ assert.deepStrictEqual(requests, ['/info/dedup?scope_id=app%3AappB'])
1258
+ assert.match(dom.window.document.getElementById('btn-scan').textContent, /Scan this app/)
1259
+ assert.strictEqual(dom.window.document.getElementById('vault-candidate-size-label').textContent, 'Check files')
1260
+ assert.strictEqual(dom.window.document.getElementById('vault-candidate-size').value,
1261
+ String(100 * 1000 * 1000))
1262
+ assert.match(dom.window.document.querySelector('.vault-summary-side').textContent, /Not scanned yet/)
1263
+ assert.doesNotMatch(dom.window.document.querySelector('.vault-summary-side').textContent, /No more matches/)
1264
+ assert.strictEqual(dom.window.document.querySelector('[data-source="app:appB"]').getAttribute('aria-current'), 'page')
1265
+ assert.strictEqual(dom.window.document.getElementById('btn-add-source'), null)
1266
+ assert.strictEqual(dom.window.document.getElementById('btn-repair'), null)
1267
+ dom.window.close()
1268
+ })
1269
+
1270
+ test('app Save space header shows proportional effective disk usage', async () => {
1271
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1272
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1273
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
1274
+ })
1275
+ const dom = new JSDOM(html, { url: 'http://localhost/vault/app/appB', runScripts: 'dangerously' })
1276
+ const gb = 1024 ** 3
1277
+ dom.window.fetch = async () => ({
1278
+ ok: true,
1279
+ json: async () => ({
1280
+ enabled: true,
1281
+ mode: 'link',
1282
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1283
+ last_scan: { ts: Date.now(), bytes_total: 6.6 * gb },
1284
+ bytes_on_disk: 2.4 * gb,
1285
+ pending_bytes: 0,
1286
+ activity_error: null,
1287
+ cloud_sync_warning: null,
1288
+ sources: [{
1289
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1290
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1291
+ }],
1292
+ blobs: [], duplicates: [], excluded: [], events: [], undo_batches: []
1293
+ })
1294
+ })
1295
+ await runVaultScript(dom)
1296
+ await new Promise((resolve) => setTimeout(resolve, 25))
1297
+
1298
+ assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '4.51 GB saved for this app')
1299
+ assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['7.09 GB', '2.58 GB'])
1300
+ assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:36\.36%/)
1301
+ assert.strictEqual(dom.window.document.getElementById('vault-after-help').textContent,
1302
+ 'Shared files are divided evenly among every location using them.')
1303
+ assert.strictEqual(dom.window.document.getElementById('vault-after-help').parentElement,
1304
+ dom.window.document.body)
1305
+ const helpButton = dom.window.document.querySelector('.vault-compare-info')
1306
+ helpButton.dispatchEvent(new dom.window.MouseEvent('mouseover', { bubbles: true }))
1307
+ assert.strictEqual(dom.window.document.getElementById('vault-after-help').hidden, false)
1308
+ helpButton.dispatchEvent(new dom.window.MouseEvent('mouseout', { bubbles: true }))
1309
+ assert.strictEqual(dom.window.document.getElementById('vault-after-help').hidden, true)
1310
+ assert.match(dom.window.document.querySelector('.vault-summary-side').textContent,
1311
+ /No more matches in files 100 MB and larger/)
1312
+ dom.window.close()
1313
+ })
1314
+
1315
+ test('candidate minimum is visible and changes only through Apply & scan', async () => {
1316
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1317
+ const workspace = await ejs.renderFile(path.resolve(views, 'partials', 'vault_workspace.ejs'), { appMode: false })
1318
+ const dom = new JSDOM(`<body data-vault-mode="global">${workspace}</body>`, {
1319
+ url: 'http://localhost/vault', runScripts: 'dangerously'
1320
+ })
1321
+ const actions = []
1322
+ const status = {
1323
+ enabled: true,
1324
+ mode: 'link',
1325
+ candidate_min_bytes: 100 * 1000 * 1000,
1326
+ candidate_size_options: [10, 50, 100, 500, 1000].map((value) => value * 1000 * 1000),
1327
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1328
+ last_scan: {
1329
+ ts: Date.now(), bytes_total: 1000, home_bytes_total: 1000,
1330
+ candidate_min_bytes: 100 * 1000 * 1000
1331
+ },
1332
+ bytes_on_disk: 1000,
1333
+ bytes_without_sharing: 1000,
1334
+ saved_by_sharing: 0,
1335
+ pending_bytes: 0,
1336
+ reclaimable: 0,
1337
+ activity_error: null,
1338
+ cloud_sync_warning: null,
1339
+ sources: [],
1340
+ blobs: [], duplicates: [], excluded: [], events: [], undo_batches: []
1341
+ }
1342
+ dom.window.fetch = async (url, options = {}) => {
1343
+ if (options.method === 'POST') {
1344
+ actions.push(JSON.parse(options.body))
1345
+ return { ok: true, json: async () => ({ started: true }) }
1346
+ }
1347
+ return { ok: true, json: async () => status }
1348
+ }
1349
+ await runVaultScript(dom)
1350
+ await new Promise((resolve) => setTimeout(resolve, 25))
1351
+
1352
+ const select = dom.window.document.getElementById('vault-candidate-size')
1353
+ assert.strictEqual(dom.window.document.getElementById('vault-candidate-size-label').textContent, 'Check files')
1354
+ assert.strictEqual(select.value, String(100 * 1000 * 1000))
1355
+ assert.deepStrictEqual([...select.options].map((option) => option.textContent), [
1356
+ '10 MB and larger', '50 MB and larger', '100 MB and larger',
1357
+ '500 MB and larger', '1 GB and larger'
1358
+ ])
1359
+ assert.match(dom.window.document.querySelector('.vault-summary-side').textContent,
1360
+ /No more matches in files 100 MB and larger/)
1361
+ assert.match(dom.window.document.getElementById('vault-pane-footer').textContent,
1362
+ /Scans check files 100 MB and larger/)
1363
+
1364
+ select.value = String(10 * 1000 * 1000)
1365
+ select.dispatchEvent(new dom.window.Event('change', { bubbles: true }))
1366
+ assert.match(dom.window.document.getElementById('btn-scan').textContent, /Apply & scan/)
1367
+ assert.deepStrictEqual(actions, [], 'changing the selector alone does not start a scan')
1368
+
1369
+ dom.window.document.getElementById('btn-scan').click()
1370
+ await new Promise((resolve) => setTimeout(resolve, 25))
1371
+ assert.deepStrictEqual(actions, [{
1372
+ action: 'scan',
1373
+ scope_id: null,
1374
+ candidate_min_bytes: 10 * 1000 * 1000
1375
+ }])
1376
+ dom.window.close()
1377
+ })
1378
+
1379
+ test('app header and Save space share decimal storage units', async () => {
1380
+ const dom = new JSDOM('', { runScripts: 'dangerously' })
1381
+ const formatter = await fs.promises.readFile(
1382
+ path.resolve(__dirname, '..', 'server', 'public', 'storage-size.js'), 'utf8')
1383
+ dom.window.eval(formatter)
1384
+ assert.strictEqual(dom.window.PinokioFormatStorageSize(7.05e9), '7.05 GB')
1385
+ assert.strictEqual(dom.window.PinokioFormatStorageSize(5.3e9), '5.3 GB')
1386
+
1387
+ const appView = await fs.promises.readFile(
1388
+ path.resolve(__dirname, '..', 'server', 'views', 'app.ejs'), 'utf8')
1389
+ assert.match(appView, /<script src="\/storage-size\.js"><\/script>/)
1390
+ assert.match(appView, /PinokioFormatStorageSize\(res\.du\)/)
1391
+ dom.window.close()
1392
+ })
1393
+
1394
+ test('global Save space header explains current savings and keeps pending savings actionable', async () => {
1395
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1396
+ const workspace = await ejs.renderFile(path.resolve(views, 'partials', 'vault_workspace.ejs'), { appMode: false })
1397
+ const dom = new JSDOM(`<body data-vault-mode="global">${workspace}</body>`, {
1398
+ url: 'http://localhost/vault', runScripts: 'dangerously'
1399
+ })
1400
+ const gb = 1024 ** 3
1401
+ dom.window.fetch = async () => ({
1402
+ ok: true,
1403
+ json: async () => ({
1404
+ enabled: true,
1405
+ mode: 'link',
1406
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1407
+ last_scan: { ts: Date.now(), bytes_total: 154.8 * gb, home_bytes_total: 154.8 * gb },
1408
+ bytes_on_disk: 377 * gb,
1409
+ bytes_without_sharing: 616.3 * gb,
1410
+ saved_by_sharing: 239.3 * gb,
1411
+ pending_bytes: 12.4 * gb,
1412
+ reclaimable: 2048,
1413
+ activity_error: null,
1414
+ cloud_sync_warning: null,
1415
+ sources: [{
1416
+ id: 'pinokio', kind: 'pinokio', label: 'Pinokio', root: '/pinokio',
1417
+ display_path: '/pinokio', parent_id: null, available: true, shareable: true
1418
+ }],
1419
+ blobs: [{ hash: 'c'.repeat(64), size: 2048, orphan: true, nlink: 1, names: [] }],
1420
+ duplicates: [], excluded: [], events: [], undo_batches: []
1421
+ })
1422
+ })
1423
+ await runVaultScript(dom)
1424
+ await new Promise((resolve) => setTimeout(resolve, 25))
1425
+
1426
+ assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '256.95 GB of disk space saved')
1427
+ assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['Before', 'After'])
1428
+ assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['661.75 GB', '404.8 GB'])
1429
+ assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:61\.17%/)
1430
+ assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent, 'Estimated space if every app stored its own copy.')
1431
+ assert.strictEqual(dom.window.document.querySelector('.vault-compare-info').tagName, 'BUTTON')
1432
+ assert.match(dom.window.document.querySelector('.vault-summary-side').textContent, /13\.31 GB more can be saved/)
1433
+ assert.strictEqual(dom.window.document.getElementById('btn-review-metric').textContent, 'Review files')
1434
+ assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), true)
1435
+ assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('review-primary'), true)
1436
+ assert.strictEqual(dom.window.document.getElementById('btn-scan').classList.contains('primary'), false)
1437
+ assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Pinokio folder\s*166\.22 GB/)
1438
+ assert.doesNotMatch(dom.window.document.getElementById('vault-storage-details').textContent, /Saved by your actions/)
1439
+ const unusedView = dom.window.document.querySelector('[data-view="reclaimable"]')
1440
+ assert.strictEqual(unusedView.querySelector('.vault-nav-name').textContent, 'Unused files')
1441
+ unusedView.click()
1442
+ assert.strictEqual(dom.window.document.getElementById('btn-reclaim-all').textContent, 'Delete all')
1443
+ assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, 'Delete')
1444
+ assert.match(dom.window.document.getElementById('vault-pane-footer').textContent, /Deleting them frees disk space/)
1445
+ dom.window.close()
1446
+ })
1447
+
1448
+ test('undo sharing explains each safe refusal instead of showing a generic failure', async () => {
1449
+ const source = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'public', 'vault.js'), 'utf8')
1450
+ assert.match(source, /const detachFeedback = \(result\) =>/)
1451
+ assert.match(source, /result\.status === "detached"\) return COPY\.sharing_undone/)
1452
+ assert.match(source, /locked:\s*COPY\.separate_locked/)
1453
+ assert.match(source, /stale:\s*COPY\.separate_changed/)
1454
+ assert.match(source, /conflict:\s*COPY\.separate_conflict/)
1455
+ assert.match(source, /"not-found":\s*COPY\.separate_not_found/)
1456
+ assert.match(source, /action: "detach", path: target\.dataset\.detach[\s\S]*?detachFeedback[\s\S]*?filePath: sharingSwitch/)
1457
+ })
1458
+
1459
+ test('inventory puts sharing switches in status and keeps duplicate Skip inline', async () => {
1460
+ const source = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'public', 'vault.js'), 'utf8')
1461
+ assert.match(source, /const duplicateAction = \(item\) => \{[\s\S]*?data-skip[\s\S]*?COPY\.skip/)
1462
+ assert.match(source, /action: "skip", path: target\.dataset\.skip/)
1463
+ assert.match(source, /item\.status === "shared"[\s\S]*?role="switch" aria-checked="true"[\s\S]*?data-detach/)
1464
+ assert.match(source, /item\.status === "independent"[\s\S]*?role="switch" aria-checked="false"[\s\S]*?data-reshare/)
1465
+ assert.match(source, /class="vault-status-cell"/)
1466
+ assert.doesNotMatch(source, /separate:\s*"Separate"/)
1467
+ assert.doesNotMatch(source, /include_in_scans:\s*"Include in scans"/)
1468
+ assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.status\]/)
1469
+ assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.matches, COPY\.can_save, ""\]/)
1470
+ assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.can_free, ""\]/)
1471
+ assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.last_scanned, ""\]/)
1472
+ assert.match(source, /reclaimable:\s*"Unused files"/)
1473
+ assert.match(source, /reclaim:\s*"Delete"/)
1474
+ assert.match(source, /reclaim_all:\s*"Delete all"/)
1475
+ assert.match(source, /data-sort-size/)
1476
+ assert.match(source, /data-display-mode="folders"/)
1477
+ assert.match(source, /data-display-mode="files"/)
1478
+ assert.match(source, /headers = \[COPY\.name, COPY\.location_column, COPY\.size, COPY\.status\]/)
1479
+ assert.match(source, /aria-sort="\$\{state\.sizeSort === "desc" \? "descending" : state\.sizeSort === "asc" \? "ascending" : "none"\}"/)
1480
+ })
1481
+
1482
+ test('app inventory renders real sharing switches without empty columns', async () => {
1483
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1484
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1485
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
1486
+ })
1487
+ const dom = new JSDOM(html, { url: 'http://localhost/vault/app/appB', runScripts: 'dangerously' })
1488
+ const actions = []
1489
+ const status = {
1490
+ enabled: true,
1491
+ mode: 'link',
1492
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1493
+ last_scan: { ts: Date.now(), bytes_total: 2048 },
1494
+ bytes_on_disk: 1536,
1495
+ pending_bytes: 0,
1496
+ activity_error: null,
1497
+ cloud_sync_warning: null,
1498
+ sources: [{
1499
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1500
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1501
+ }],
1502
+ blobs: [{
1503
+ hash: 'a'.repeat(64), size: 1024, orphan: false, nlink: 3,
1504
+ names: [
1505
+ { path: '/pinokio/api/appB/shared.bin', relative_path: 'shared.bin', source_id: 'app:appB', source_label: 'appB', mode: 'link' },
1506
+ { path: '/pinokio/api/appA/shared.bin', relative_path: 'shared.bin', source_id: 'app:appA', source_label: 'appA', mode: 'link' }
1507
+ ]
1508
+ }],
1509
+ duplicates: [],
1510
+ excluded: [{
1511
+ path: '/pinokio/api/appB/own.bin', relative_path: 'own.bin',
1512
+ source_id: 'app:appB', source_label: 'appB', size: 1024
1513
+ }],
1514
+ events: [], undo_batches: []
1515
+ }
1516
+ dom.window.fetch = async (url, options = {}) => {
1517
+ if (options.method === 'POST') {
1518
+ const action = JSON.parse(options.body)
1519
+ actions.push(action)
1520
+ return { ok: true, json: async () => action.action === 'reshare' ? { status: 'resharable' } : { status: 'detached' } }
1521
+ }
1522
+ return { ok: true, json: async () => status }
1523
+ }
1524
+ await runVaultScript(dom)
1525
+ await new Promise((resolve) => setTimeout(resolve, 25))
1526
+
1527
+ assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-table.inventory .vault-columns span')]
1528
+ .map((node) => node.textContent), ['Name', 'Size', 'Sharing status'])
1529
+ assert.strictEqual(dom.window.document.querySelector('.vault-table.inventory .vault-space'), null)
1530
+ assert.strictEqual(dom.window.document.querySelector('.vault-table.inventory .vault-row-action'), null)
1531
+ const on = dom.window.document.querySelector('.vault-sharing-switch[aria-checked="true"]')
1532
+ const off = dom.window.document.querySelector('.vault-sharing-switch[aria-checked="false"]')
1533
+ assert.ok(on && on.classList.contains('on'))
1534
+ assert.ok(off && !off.classList.contains('on'))
1535
+ assert.strictEqual(on.getAttribute('role'), 'switch')
1536
+ assert.strictEqual(on.getAttribute('aria-label'), 'Undo sharing for shared.bin')
1537
+ assert.strictEqual(off.getAttribute('aria-label'), 'Allow own.bin to share storage')
1538
+ assert.match(off.closest('.vault-status-cell').textContent, /Kept separate/)
1539
+
1540
+ on.click()
1541
+ await new Promise((resolve) => setTimeout(resolve, 25))
1542
+ dom.window.document.querySelector('.vault-sharing-switch[aria-checked="false"]').click()
1543
+ await new Promise((resolve) => setTimeout(resolve, 25))
1544
+ assert.deepStrictEqual(actions, [
1545
+ { action: 'detach', path: '/pinokio/api/appB/shared.bin' },
1546
+ { action: 'reshare', path: '/pinokio/api/appB/own.bin' }
1547
+ ])
1548
+ dom.window.close()
1549
+ })
1550
+
1551
+ test('turning sharing off queues immediately, then shows determinate row progress', async () => {
1552
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1553
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1554
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
1555
+ })
1556
+ const dom = new JSDOM(html, {
1557
+ url: 'http://localhost/vault/app/appB', runScripts: 'dangerously'
1558
+ })
1559
+ const sharedPath = '/pinokio/api/appB/shared.bin'
1560
+ const status = {
1561
+ enabled: true,
1562
+ mode: 'link',
1563
+ candidate_min_bytes: 100000000,
1564
+ candidate_size_options: [100000000],
1565
+ file_action: null,
1566
+ action_sequence: 0,
1567
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1568
+ last_scan: { ts: Date.now(), bytes_total: 2048, candidate_min_bytes: 100000000 },
1569
+ bytes_on_disk: 512,
1570
+ pending_bytes: 0,
1571
+ activity_error: null,
1572
+ cloud_sync_warning: null,
1573
+ sources: [{
1574
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1575
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1576
+ }],
1577
+ blobs: [{
1578
+ hash: 'a'.repeat(64), size: 1024, orphan: false, nlink: 3,
1579
+ names: [
1580
+ {
1581
+ path: sharedPath, relative_path: 'shared.bin',
1582
+ source_id: 'app:appB', source_label: 'appB', mode: 'link'
1583
+ },
1584
+ {
1585
+ path: '/pinokio/api/appA/shared.bin', relative_path: 'shared.bin',
1586
+ source_id: 'app:appA', source_label: 'appA', mode: 'link'
1587
+ }
1588
+ ]
1589
+ }],
1590
+ duplicates: [], excluded: [], events: [], undo_batches: []
1591
+ }
1592
+ let finishDetach
1593
+ let finishStaleProgress
1594
+ const detachGate = new Promise((resolve) => { finishDetach = resolve })
1595
+ const staleProgressGate = new Promise((resolve) => { finishStaleProgress = resolve })
1596
+ let detachFinished = false
1597
+ let progressReads = 0
1598
+ dom.window.fetch = async (url, options = {}) => {
1599
+ if (options.method === 'POST') {
1600
+ await detachGate
1601
+ detachFinished = true
1602
+ return { ok: true, json: async () => ({ status: 'detached' }) }
1603
+ }
1604
+ if (String(url).includes('progress=1')) {
1605
+ progressReads += 1
1606
+ if (progressReads === 2) await staleProgressGate
1607
+ return {
1608
+ ok: true,
1609
+ json: async () => ({
1610
+ enabled: true,
1611
+ action_sequence: 1,
1612
+ file_action: {
1613
+ kind: 'detach',
1614
+ path: sharedPath,
1615
+ source_id: 'app:appB',
1616
+ bytes_copied: 512,
1617
+ bytes_total: 1024
1618
+ },
1619
+ scan: status.scan,
1620
+ last_scan: status.last_scan
1621
+ })
1622
+ }
1623
+ }
1624
+ return {
1625
+ ok: true,
1626
+ json: async () => Object.assign({}, status, {
1627
+ action_sequence: detachFinished ? 2 : 0,
1628
+ file_action: null,
1629
+ file_actions: []
1630
+ })
1631
+ }
1632
+ }
1633
+ await runVaultScript(dom)
1634
+ await new Promise((resolve) => setTimeout(resolve, 25))
1635
+
1636
+ dom.window.document.querySelector('.vault-sharing-switch[aria-checked="true"]').click()
1637
+ assert.match(dom.window.document.querySelector('.vault-file-action-queued').textContent,
1638
+ /Queued to undo/)
1639
+ const queuedRow = dom.window.document.querySelector(`[data-file-path="${sharedPath}"]`)
1640
+
1641
+ await new Promise((resolve) => setTimeout(resolve, 240))
1642
+ let progress = dom.window.document.querySelector('.vault-file-action-track')
1643
+ assert.strictEqual(dom.window.document.querySelector(`[data-file-path="${sharedPath}"]`), queuedRow,
1644
+ 'progress polling updates the action cell without rebuilding the row')
1645
+ assert.strictEqual(progress.getAttribute('aria-valuenow'), '512')
1646
+ assert.strictEqual(progress.getAttribute('aria-valuemax'), '1024')
1647
+ assert.match(progress.closest('.vault-file-action-progress').textContent, /512 B of 1\.02 KB/)
1648
+
1649
+ finishDetach()
1650
+ await new Promise((resolve) => setTimeout(resolve, 30))
1651
+ finishStaleProgress()
1652
+ await new Promise((resolve) => setTimeout(resolve, 30))
1653
+ assert.strictEqual(dom.window.document.querySelector('.vault-file-action-track'), null)
1654
+ dom.window.close()
1655
+ })
1656
+
1657
+ test('multiple sharing switches keep queued feedback while copies run serially', async () => {
1658
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1659
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1660
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
1661
+ })
1662
+ const dom = new JSDOM(html, {
1663
+ url: 'http://localhost/vault/app/appB', runScripts: 'dangerously'
1664
+ })
1665
+ const firstPath = '/pinokio/api/appB/first.bin'
1666
+ const secondPath = '/pinokio/api/appB/second.bin'
1667
+ const status = {
1668
+ enabled: true,
1669
+ mode: 'link',
1670
+ candidate_min_bytes: 100000000,
1671
+ candidate_size_options: [100000000],
1672
+ file_action: null,
1673
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1674
+ last_scan: { ts: Date.now(), bytes_total: 4096, candidate_min_bytes: 100000000 },
1675
+ bytes_on_disk: 1024,
1676
+ pending_bytes: 0,
1677
+ activity_error: null,
1678
+ cloud_sync_warning: null,
1679
+ sources: [{
1680
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1681
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1682
+ }],
1683
+ blobs: [
1684
+ {
1685
+ hash: 'a'.repeat(64), size: 1024, orphan: false, nlink: 3,
1686
+ names: [
1687
+ {
1688
+ path: firstPath, relative_path: 'first.bin',
1689
+ source_id: 'app:appB', source_label: 'appB', mode: 'link'
1690
+ },
1691
+ {
1692
+ path: '/pinokio/api/appA/first.bin', relative_path: 'first.bin',
1693
+ source_id: 'app:appA', source_label: 'appA', mode: 'link'
1694
+ }
1695
+ ]
1696
+ },
1697
+ {
1698
+ hash: 'b'.repeat(64), size: 1024, orphan: false, nlink: 3,
1699
+ names: [
1700
+ {
1701
+ path: secondPath, relative_path: 'second.bin',
1702
+ source_id: 'app:appB', source_label: 'appB', mode: 'link'
1703
+ },
1704
+ {
1705
+ path: '/pinokio/api/appA/second.bin', relative_path: 'second.bin',
1706
+ source_id: 'app:appA', source_label: 'appA', mode: 'link'
1707
+ }
1708
+ ]
1709
+ }
1710
+ ],
1711
+ duplicates: [], excluded: [], events: [], undo_batches: []
1712
+ }
1713
+ let finishFirst
1714
+ let finishSecond
1715
+ let finishStaleRefresh
1716
+ let markFirstDone
1717
+ const firstGate = new Promise((resolve) => { finishFirst = resolve })
1718
+ const secondGate = new Promise((resolve) => { finishSecond = resolve })
1719
+ const staleRefreshGate = new Promise((resolve) => { finishStaleRefresh = resolve })
1720
+ const firstDone = new Promise((resolve) => { markFirstDone = resolve })
1721
+ let activePath = null
1722
+ let fullReads = 0
1723
+ dom.window.fetch = async (url, options = {}) => {
1724
+ if (options.method === 'POST') {
1725
+ const action = JSON.parse(options.body)
1726
+ if (action.path === firstPath) {
1727
+ activePath = firstPath
1728
+ await firstGate
1729
+ activePath = null
1730
+ markFirstDone()
1731
+ } else {
1732
+ await firstDone
1733
+ activePath = secondPath
1734
+ await secondGate
1735
+ activePath = null
1736
+ }
1737
+ return { ok: true, json: async () => ({ status: 'detached' }) }
1738
+ }
1739
+ if (String(url).includes('progress=1')) {
1740
+ return {
1741
+ ok: true,
1742
+ json: async () => ({
1743
+ enabled: true,
1744
+ file_action: activePath
1745
+ ? {
1746
+ kind: 'detach',
1747
+ path: activePath,
1748
+ source_id: 'app:appB',
1749
+ bytes_copied: 512,
1750
+ bytes_total: 1024
1751
+ }
1752
+ : null,
1753
+ scan: status.scan,
1754
+ last_scan: status.last_scan
1755
+ })
1756
+ }
1757
+ }
1758
+ const read = ++fullReads
1759
+ if (read === 2) await staleRefreshGate
1760
+ const currentStatus = read >= 3
1761
+ ? Object.assign({}, status, {
1762
+ bytes_on_disk: 4096,
1763
+ pending_bytes: 2048
1764
+ })
1765
+ : status
1766
+ return {
1767
+ ok: true,
1768
+ json: async () => Object.assign({}, currentStatus, {
1769
+ file_action: activePath
1770
+ ? {
1771
+ kind: 'detach',
1772
+ path: activePath,
1773
+ source_id: 'app:appB',
1774
+ bytes_copied: 512,
1775
+ bytes_total: 1024
1776
+ }
1777
+ : null
1778
+ })
1779
+ }
1780
+ }
1781
+ await runVaultScript(dom)
1782
+ await new Promise((resolve) => setTimeout(resolve, 25))
1783
+
1784
+ try {
1785
+ const switchFor = (filePath) => [...dom.window.document.querySelectorAll('.vault-sharing-switch')]
1786
+ .find((button) => button.dataset.detach === filePath)
1787
+ switchFor(firstPath).click()
1788
+ switchFor(secondPath).click()
1789
+ assert.strictEqual(dom.window.document.querySelectorAll('.vault-file-action-queued').length, 2)
1790
+
1791
+ await new Promise((resolve) => setTimeout(resolve, 240))
1792
+ assert.strictEqual(dom.window.document.querySelectorAll('.vault-file-action-progress').length, 1)
1793
+ assert.strictEqual(dom.window.document.querySelectorAll('.vault-file-action-queued').length, 1)
1794
+
1795
+ finishFirst()
1796
+ await new Promise((resolve) => setTimeout(resolve, 240))
1797
+ assert.strictEqual(dom.window.document.querySelectorAll('.vault-file-action-progress').length, 1)
1798
+ assert.strictEqual(dom.window.document.querySelectorAll('.vault-file-action-queued').length, 1)
1799
+
1800
+ finishSecond()
1801
+ await new Promise((resolve) => setTimeout(resolve, 30))
1802
+ assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent,
1803
+ 'No disk space is currently being saved')
1804
+ assert.match(dom.window.document.querySelector('.vault-summary-side').textContent,
1805
+ /2\.05 KB available to save/)
1806
+
1807
+ finishStaleRefresh()
1808
+ await new Promise((resolve) => setTimeout(resolve, 30))
1809
+ assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent,
1810
+ 'No disk space is currently being saved')
1811
+ assert.strictEqual(dom.window.document.querySelectorAll('.vault-file-action-progress').length, 0)
1812
+ assert.strictEqual(dom.window.document.querySelectorAll('.vault-file-action-queued').length, 0)
1813
+ } finally {
1814
+ finishFirst()
1815
+ finishSecond()
1816
+ finishStaleRefresh()
1817
+ await new Promise((resolve) => setTimeout(resolve, 50))
1818
+ dom.window.close()
1819
+ }
1820
+ })
1821
+
1822
+ test('Shared defaults to a globally size-sorted Files mode and can return to Folders', async () => {
1823
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1824
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1825
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
1826
+ })
1827
+ const dom = new JSDOM(html, { url: 'http://localhost/vault/app/appB', runScripts: 'dangerously' })
1828
+ const sharedBlob = (hash, size, name) => ({
1829
+ hash, size, orphan: false, nlink: 3,
1830
+ names: [
1831
+ { path: `/pinokio/api/appB/${name}`, relative_path: name, source_id: 'app:appB', source_label: 'appB', mode: 'link' },
1832
+ { path: `/pinokio/api/appA/${name}`, relative_path: name, source_id: 'app:appA', source_label: 'appA', mode: 'link' }
1833
+ ]
1834
+ })
1835
+ const status = {
1836
+ enabled: true,
1837
+ mode: 'link',
1838
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1839
+ last_scan: { ts: Date.now(), bytes_total: 5120 },
1840
+ bytes_on_disk: 2560,
1841
+ pending_bytes: 0,
1842
+ activity_error: null,
1843
+ cloud_sync_warning: null,
1844
+ sources: [{
1845
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1846
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1847
+ }],
1848
+ blobs: [
1849
+ sharedBlob('a'.repeat(64), 1024, 'a-small/a-small.bin'),
1850
+ sharedBlob('b'.repeat(64), 4096, 'z-large/z-large.bin')
1851
+ ],
1852
+ duplicates: [], excluded: [], events: [], undo_batches: []
1853
+ }
1854
+ dom.window.fetch = async () => ({ ok: true, json: async () => status })
1855
+ await runVaultScript(dom)
1856
+ await new Promise((resolve) => setTimeout(resolve, 25))
1857
+
1858
+ dom.window.document.querySelector('[data-view="shared"]').click()
1859
+ const names = () => [...dom.window.document.querySelectorAll('.vault-file-row:not(.directory) .vault-file-name')]
1860
+ .map((node) => node.textContent)
1861
+ const directories = () => [...dom.window.document.querySelectorAll('.vault-file-row.directory .vault-file-name')]
1862
+ .map((node) => node.textContent)
1863
+ const locations = () => [...dom.window.document.querySelectorAll('.vault-flat-location')]
1864
+ .map((node) => node.textContent)
1865
+ const sort = () => dom.window.document.querySelector('[data-sort-size]')
1866
+ const mode = (name) => dom.window.document.querySelector(`[data-display-mode="${name}"]`)
1867
+ assert.strictEqual(mode('files').getAttribute('aria-pressed'), 'true')
1868
+ assert.strictEqual(mode('folders').getAttribute('aria-pressed'), 'false')
1869
+ assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-columns > span')]
1870
+ .map((node) => node.textContent), ['Name', 'Location', 'Size', 'Sharing status'])
1871
+ assert.deepStrictEqual(names(), ['z-large.bin', 'a-small.bin'])
1872
+ assert.deepStrictEqual(directories(), [])
1873
+ assert.match(locations()[0], /appB \/ z-large\/z-large\.bin/)
1874
+ assert.match(locations()[1], /appB \/ a-small\/a-small\.bin/)
1875
+ assert.strictEqual(sort().getAttribute('aria-label'), 'Sort by size, smallest first')
1876
+ assert.strictEqual(sort().parentElement.getAttribute('aria-sort'), 'descending')
1877
+ assert.strictEqual(dom.window.document.getElementById('vault-pane-footer').textContent,
1878
+ '2 shared files · sorted largest first')
1879
+
1880
+ sort().click()
1881
+ assert.deepStrictEqual(names(), ['a-small.bin', 'z-large.bin'])
1882
+ assert.strictEqual(sort().getAttribute('aria-label'), 'Sort by size, largest first')
1883
+ assert.strictEqual(sort().parentElement.getAttribute('aria-sort'), 'ascending')
1884
+ assert.strictEqual(dom.window.document.getElementById('vault-pane-footer').textContent,
1885
+ '2 shared files · sorted smallest first')
1886
+
1887
+ sort().click()
1888
+ assert.deepStrictEqual(names(), ['z-large.bin', 'a-small.bin'])
1889
+ assert.strictEqual(sort().getAttribute('aria-label'), 'Sort by size, smallest first')
1890
+ assert.strictEqual(sort().parentElement.getAttribute('aria-sort'), 'descending')
1891
+
1892
+ mode('folders').click()
1893
+ assert.strictEqual(mode('folders').getAttribute('aria-pressed'), 'true')
1894
+ assert.strictEqual(mode('files').getAttribute('aria-pressed'), 'false')
1895
+ assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-columns > span')]
1896
+ .map((node) => node.textContent), ['Name', 'Size', 'Sharing status'])
1897
+ assert.deepStrictEqual(directories(), ['a-small', 'z-large'])
1898
+ assert.strictEqual(sort(), null)
1899
+
1900
+ mode('files').click()
1901
+ assert.deepStrictEqual(names(), ['z-large.bin', 'a-small.bin'])
1902
+ assert.strictEqual(sort().parentElement.getAttribute('aria-sort'), 'descending')
1903
+ dom.window.close()
1904
+ })
1905
+
1906
+ test('Duplicates uses one two-state Size control for groups and their files', async () => {
1907
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1908
+ const workspace = await ejs.renderFile(
1909
+ path.resolve(views, 'partials', 'vault_workspace.ejs'),
1910
+ { appMode: false }
1911
+ )
1912
+ const dom = new JSDOM(`<body data-vault-mode="global">${workspace}</body>`, {
1913
+ url: 'http://localhost/vault',
1914
+ runScripts: 'dangerously'
1915
+ })
1916
+ const duplicate = (sourceId, name, size) => ({
1917
+ path: `/pinokio/api/${sourceId.slice(4)}/${name}`,
1918
+ relative_path: name,
1919
+ source_id: sourceId,
1920
+ source_label: sourceId.slice(4),
1921
+ size,
1922
+ shareable: true,
1923
+ match: { path: `/pinokio/cache/${name}` }
1924
+ })
1925
+ const status = {
1926
+ enabled: true,
1927
+ mode: 'link',
1928
+ candidate_min_bytes: 100 * 1000 * 1000,
1929
+ candidate_size_options: [100 * 1000 * 1000],
1930
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1931
+ last_scan: { ts: Date.now(), bytes_total: 1400, home_bytes_total: 1400 },
1932
+ last_attempt: null,
1933
+ bytes_without_sharing: 1400,
1934
+ bytes_on_disk: 1400,
1935
+ saved_by_sharing: 0,
1936
+ pending_bytes: 1400,
1937
+ reclaimable: 0,
1938
+ activity_error: null,
1939
+ cloud_sync_warning: null,
1940
+ sources: [
1941
+ { id: 'pinokio', kind: 'pinokio', label: 'Pinokio', root: '/pinokio', parent_id: null, available: true, shareable: true },
1942
+ { id: 'apps', kind: 'virtual', label: 'Apps', root: '/pinokio/api', parent_id: 'pinokio', available: true, shareable: null },
1943
+ { id: 'app:appA', kind: 'app', label: 'appA', root: '/pinokio/api/appA', parent_id: 'apps', available: true, shareable: true },
1944
+ { id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB', parent_id: 'apps', available: true, shareable: true }
1945
+ ],
1946
+ blobs: [],
1947
+ duplicates: [
1948
+ duplicate('app:appA', 'a-small.bin', 100),
1949
+ duplicate('app:appA', 'a-large.bin', 300),
1950
+ duplicate('app:appB', 'b-largest.bin', 1000)
1951
+ ],
1952
+ excluded: [],
1953
+ events: [],
1954
+ undo_batches: [],
1955
+ file_action: null,
1956
+ file_actions: [],
1957
+ action_sequence: 0
1958
+ }
1959
+ let finishDeduplicateAll
1960
+ const deduplicateAllGate = new Promise((resolve) => { finishDeduplicateAll = resolve })
1961
+ let deduplicateAllFinished = false
1962
+ let submittedAction = null
1963
+ dom.window.fetch = async (url, options = {}) => {
1964
+ if (options.method === 'POST') {
1965
+ submittedAction = JSON.parse(options.body)
1966
+ await deduplicateAllGate
1967
+ deduplicateAllFinished = true
1968
+ status.duplicates = []
1969
+ status.pending_bytes = 0
1970
+ return { ok: true, json: async () => ({ converted: 3, bytes_saved: 1400 }) }
1971
+ }
1972
+ if (String(url).includes('progress=1')) {
1973
+ const action = deduplicateAllFinished ? null : {
1974
+ id: 'action-all',
1975
+ kind: 'deduplicate_all',
1976
+ phase: 'deduplicating',
1977
+ path: null,
1978
+ source_id: null,
1979
+ bytes_copied: 0,
1980
+ bytes_total: 0,
1981
+ files_completed: 2,
1982
+ files_total: 3
1983
+ }
1984
+ return {
1985
+ ok: true,
1986
+ json: async () => ({
1987
+ enabled: true,
1988
+ action_sequence: deduplicateAllFinished ? 2 : 1,
1989
+ file_action: action,
1990
+ file_actions: action ? [action] : [],
1991
+ scan: status.scan,
1992
+ last_scan: status.last_scan
1993
+ })
1994
+ }
1995
+ }
1996
+ return {
1997
+ ok: true,
1998
+ json: async () => Object.assign({}, status, {
1999
+ action_sequence: deduplicateAllFinished ? 2 : 0,
2000
+ file_action: null,
2001
+ file_actions: []
2002
+ })
2003
+ }
2004
+ }
2005
+ await runVaultScript(dom)
2006
+ await new Promise((resolve) => setTimeout(resolve, 25))
2007
+
2008
+ try {
2009
+ dom.window.document.querySelector('[data-view="duplicates"]').click()
2010
+ const groupNames = () => [...dom.window.document.querySelectorAll('.vault-group-title span')]
2011
+ .map((node) => node.textContent)
2012
+ const fileNames = () => [...dom.window.document.querySelectorAll('.vault-file-row .vault-file-name')]
2013
+ .map((node) => node.textContent)
2014
+ const sort = () => dom.window.document.querySelector('[data-sort-size]')
2015
+
2016
+ assert.deepStrictEqual(groupNames(), ['Pinokio / Apps / appB', 'Pinokio / Apps / appA'])
2017
+ assert.deepStrictEqual(fileNames(), ['b-largest.bin', 'a-large.bin', 'a-small.bin'])
2018
+ assert.strictEqual(sort().parentElement.getAttribute('aria-sort'), 'descending')
2019
+
2020
+ sort().click()
2021
+ assert.deepStrictEqual(groupNames(), ['Pinokio / Apps / appA', 'Pinokio / Apps / appB'])
2022
+ assert.deepStrictEqual(fileNames(), ['a-small.bin', 'a-large.bin', 'b-largest.bin'])
2023
+ assert.strictEqual(sort().parentElement.getAttribute('aria-sort'), 'ascending')
2024
+
2025
+ sort().click()
2026
+ assert.deepStrictEqual(groupNames(), ['Pinokio / Apps / appB', 'Pinokio / Apps / appA'])
2027
+ assert.strictEqual(sort().parentElement.getAttribute('aria-sort'), 'descending')
2028
+
2029
+ const search = dom.window.document.getElementById('vault-search')
2030
+ search.value = 'a-small'
2031
+ search.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
2032
+ assert.match(dom.window.document.getElementById('btn-deduplicate-all').textContent, /Deduplicate all 1 file/)
2033
+ search.value = 'missing'
2034
+ search.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
2035
+ assert.strictEqual(dom.window.document.querySelector('[data-deduplicate-all-action]'), null)
2036
+ search.value = ''
2037
+ search.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
2038
+
2039
+ const all = dom.window.document.getElementById('btn-deduplicate-all')
2040
+ assert.match(all.textContent, /Deduplicate all 3 files/)
2041
+ all.click()
2042
+ assert.match(dom.window.document.querySelector('[data-deduplicate-all-action]').textContent, /Queued/)
2043
+ assert.ok([...dom.window.document.querySelectorAll('[data-deduplicate-scope]')]
2044
+ .every((button) => button.disabled))
2045
+
2046
+ await new Promise((resolve) => setTimeout(resolve, 240))
2047
+ const progress = dom.window.document.querySelector('[data-deduplicate-all-action] .vault-deduplicate-progress')
2048
+ assert.match(progress.closest('button').textContent, /Deduplicating 2 of 3/)
2049
+ assert.strictEqual(progress.getAttribute('aria-valuenow'), '2')
2050
+ assert.deepStrictEqual(submittedAction, {
2051
+ action: 'deduplicate_all',
2052
+ groups: [
2053
+ {
2054
+ scope_id: 'app:appA',
2055
+ paths: ['/pinokio/api/appA/a-small.bin', '/pinokio/api/appA/a-large.bin']
2056
+ },
2057
+ {
2058
+ scope_id: 'app:appB',
2059
+ paths: ['/pinokio/api/appB/b-largest.bin']
2060
+ }
2061
+ ]
2062
+ })
2063
+ } finally {
2064
+ finishDeduplicateAll()
2065
+ await new Promise((resolve) => setTimeout(resolve, 50))
2066
+ dom.window.close()
2067
+ }
2068
+ })
2069
+
2070
+ test('Deduplicate shows queued state and exact group progress without rebuilding the explorer', async () => {
2071
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2072
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2073
+ theme: 'light', agent: 'electron', scope_id: 'app:appB'
2074
+ })
2075
+ const dom = new JSDOM(html, {
2076
+ url: 'http://localhost/vault/app/appB',
2077
+ runScripts: 'dangerously'
2078
+ })
2079
+ const duplicate = (name) => ({
2080
+ path: `/pinokio/api/appB/${name}`,
2081
+ relative_path: name,
2082
+ source_id: 'app:appB',
2083
+ source_label: 'appB',
2084
+ size: 1024,
2085
+ shareable: true,
2086
+ match: { path: `/pinokio/api/appA/${name}` }
2087
+ })
2088
+ const status = {
2089
+ enabled: true,
2090
+ mode: 'link',
2091
+ candidate_min_bytes: 100000000,
2092
+ candidate_size_options: [100000000],
2093
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2094
+ last_scan: { ts: Date.now(), bytes_total: 2048, candidate_min_bytes: 100000000 },
2095
+ last_attempt: null,
2096
+ bytes_without_sharing: 2048,
2097
+ bytes_on_disk: 2048,
2098
+ saved_by_sharing: 0,
2099
+ pending_bytes: 2048,
2100
+ reclaimable: 0,
2101
+ activity_error: null,
2102
+ cloud_sync_warning: null,
2103
+ sources: [{
2104
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
2105
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
2106
+ }],
2107
+ blobs: [],
2108
+ duplicates: [duplicate('first.bin'), duplicate('second.bin')],
2109
+ excluded: [],
2110
+ events: [],
2111
+ undo_batches: [],
2112
+ file_action: null,
2113
+ file_actions: [],
2114
+ action_sequence: 0
2115
+ }
2116
+ let finishDeduplicate
2117
+ const deduplicateGate = new Promise((resolve) => { finishDeduplicate = resolve })
2118
+ let finished = false
2119
+ dom.window.fetch = async (url, options = {}) => {
2120
+ if (options.method === 'POST') {
2121
+ await deduplicateGate
2122
+ finished = true
2123
+ status.duplicates = []
2124
+ status.pending_bytes = 0
2125
+ return {
2126
+ ok: true,
2127
+ json: async () => ({ converted: 2, bytes_saved: 2048 })
2128
+ }
2129
+ }
2130
+ if (String(url).includes('progress=1')) {
2131
+ return {
2132
+ ok: true,
2133
+ json: async () => ({
2134
+ enabled: true,
2135
+ action_sequence: finished ? 2 : 1,
2136
+ file_action: finished ? null : {
2137
+ id: 'action-1',
2138
+ kind: 'deduplicate',
2139
+ phase: 'deduplicating',
2140
+ path: null,
2141
+ source_id: 'app:appB',
2142
+ bytes_copied: 0,
2143
+ bytes_total: 0,
2144
+ files_completed: 1,
2145
+ files_total: 2
2146
+ },
2147
+ file_actions: finished ? [] : [{
2148
+ id: 'action-1',
2149
+ kind: 'deduplicate',
2150
+ phase: 'deduplicating',
2151
+ path: null,
2152
+ source_id: 'app:appB',
2153
+ bytes_copied: 0,
2154
+ bytes_total: 0,
2155
+ files_completed: 1,
2156
+ files_total: 2
2157
+ }],
2158
+ scan: status.scan,
2159
+ last_scan: status.last_scan
2160
+ })
2161
+ }
2162
+ }
2163
+ return {
2164
+ ok: true,
2165
+ json: async () => Object.assign({}, status, {
2166
+ action_sequence: finished ? 2 : 0,
2167
+ file_action: null,
2168
+ file_actions: []
2169
+ })
2170
+ }
2171
+ }
2172
+
2173
+ try {
2174
+ await runVaultScript(dom)
2175
+ await new Promise((resolve) => setTimeout(resolve, 25))
2176
+ dom.window.document.querySelector('[data-view="duplicates"]').click()
2177
+ assert.strictEqual(dom.window.document.getElementById('btn-deduplicate-all'), null)
2178
+ const group = dom.window.document.querySelector('[data-deduplicate-action-scope="app:appB"]')
2179
+ const row = dom.window.document.querySelector('[data-file-path="/pinokio/api/appB/first.bin"]')
2180
+
2181
+ group.querySelector('[data-deduplicate-scope]').click()
2182
+ assert.match(group.textContent, /Queued/)
2183
+
2184
+ await new Promise((resolve) => setTimeout(resolve, 240))
2185
+ const progress = group.querySelector('.vault-deduplicate-progress')
2186
+ assert.strictEqual(
2187
+ dom.window.document.querySelector('[data-file-path="/pinokio/api/appB/first.bin"]'),
2188
+ row,
2189
+ 'progress polling updates only the group action'
2190
+ )
2191
+ assert.match(group.textContent, /Deduplicating 1 of 2/)
2192
+ assert.strictEqual(progress.getAttribute('aria-valuenow'), '1')
2193
+ assert.strictEqual(progress.getAttribute('aria-valuemax'), '2')
2194
+ } finally {
2195
+ finishDeduplicate()
2196
+ await new Promise((resolve) => setTimeout(resolve, 50))
2197
+ dom.window.close()
2198
+ }
2199
+ })
2200
+
230
2201
  test('vault route provisions the dev requirements needed by its folder picker', async () => {
231
2202
  const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
232
2203
  const routeStart = serverSource.indexOf('this.app.get("/vault"')
@@ -240,6 +2211,113 @@ describe('vault dashboard backend (phase 4)', () => {
240
2211
  assert.ok(route.indexOf('res.redirect') < route.indexOf('res.render("vault"'), 'requirements redirect must happen before rendering Vault')
241
2212
  })
242
2213
 
2214
+ test('disabled Vault routes return no data and the sidebar entry can be hidden', async () => {
2215
+ const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
2216
+ const kernelSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'kernel', 'index.js'), 'utf8')
2217
+ const sidebar = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'partials', 'main_sidebar.ejs'), 'utf8')
2218
+ const infoRoute = serverSource.slice(serverSource.indexOf('this.app.get("/info/dedup"'), serverSource.indexOf('this.app.get("/vault"'))
2219
+ const pageRoute = serverSource.slice(serverSource.indexOf('this.app.get("/vault"'), serverSource.indexOf('this.app.post("/vault/action"'))
2220
+ const actionRoute = serverSource.slice(serverSource.indexOf('this.app.post("/vault/action"'), serverSource.indexOf('this.app.get("/info/scripts"'))
2221
+ assert.match(infoRoute, /res\.sendStatus\(404\)/)
2222
+ assert.match(pageRoute, /res\.sendStatus\(404\)/)
2223
+ assert.match(actionRoute, /res\.sendStatus\(404\)/)
2224
+ assert.match(infoRoute, /isSameOriginRequest/)
2225
+ assert.match(actionRoute, /isSameOriginRequest/)
2226
+ assert.match(sidebar, /vaultEnabled/)
2227
+ assert.match(sidebar, /fa-solid fa-hard-drive[^\n]*<div class='caption'>Save space<\/div>/)
2228
+ assert.doesNotMatch(sidebar, /<div class='caption'>Vault<\/div>/)
2229
+ assert.ok(sidebar.indexOf("<div class='caption'>Save space</div>") < sidebar.indexOf("<div class='caption'>Checkpoints</div>"))
2230
+ assert.doesNotMatch(kernelSource, /catch\(\(err\) => \{\s*this\.vault\.enabled = false/)
2231
+ })
2232
+
2233
+ test('dashboard actions delegate to the engine and deduplication requires a scope', async () => {
2234
+ const { home, vault } = await makeEnv()
2235
+ const content = crypto.randomBytes(4096)
2236
+ const hash = sha256(content)
2237
+ const original = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
2238
+ const pending = await writeFile(path.resolve(home, 'api', 'appB', 'model.bin'), content)
2239
+ await vault.adopt(original, hash, { app: 'appA' })
2240
+ vault.registry.duplicates.set(pending, { hash, size: content.length, app: 'appB' })
2241
+
2242
+ const result = await vault.perform('deduplicate', {})
2243
+
2244
+ assert.match(result.error, /location/i)
2245
+ assert.strictEqual((await fs.promises.stat(pending)).nlink, 1)
2246
+ const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
2247
+ const actionRoute = serverSource.slice(serverSource.indexOf('this.app.post("/vault/action"'), serverSource.indexOf('this.app.get("/info/scripts"'))
2248
+ assert.match(actionRoute, /vault\.perform\(body\.action, body\)/)
2249
+ assert.doesNotMatch(actionRoute, /convertPending|runExclusive/)
2250
+ })
2251
+
2252
+ test('a stale external source id cannot authorize files outside its configured root', async () => {
2253
+ const { home, vault } = await makeEnv()
2254
+ const firstTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-old-'))
2255
+ const secondTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-new-'))
2256
+ homes.push(firstTarget, secondTarget)
2257
+ const content = crypto.randomBytes(4096)
2258
+ const hash = sha256(content)
2259
+ const original = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
2260
+ const outside = await writeFile(path.resolve(firstTarget, 'model.bin'), content)
2261
+ const externalSource = (await vault.addExternalSource(firstTarget)).source
2262
+ const originalSource = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
2263
+ await vault.adopt(original, hash, { app: 'appA', source_id: originalSource.id })
2264
+ const outsideStat = await fs.promises.stat(outside)
2265
+ vault.registry.duplicates.set(outside, {
2266
+ hash, size: content.length, source_id: externalSource.id,
2267
+ dev: outsideStat.dev, ino: outsideStat.ino,
2268
+ mtime: outsideStat.mtimeMs, ctime: outsideStat.ctimeMs
2269
+ })
2270
+ vault.registry.settings.external_sources = [await fs.promises.realpath(secondTarget)]
2271
+ await vault.refreshSources()
2272
+
2273
+ const result = await vault.perform('deduplicate', { scope_id: externalSource.id })
2274
+
2275
+ assert.match(result.error, /no longer available/i)
2276
+ assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
2277
+ assert.ok(vault.registry.duplicates.has(outside))
2278
+ })
2279
+
2280
+ test('deduplication rejects a lexically in-scope path that now resolves outside its app', async (t) => {
2281
+ const { home, vault } = await makeEnv()
2282
+ const content = crypto.randomBytes(4096)
2283
+ const original = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
2284
+ const appDirectory = path.resolve(home, 'api', 'appB', 'models')
2285
+ const pending = await writeFile(path.resolve(appDirectory, 'model.bin'), content)
2286
+ await vault.sweeper.scan()
2287
+ const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
2288
+ assert.ok(vault.registry.duplicates.has(pending))
2289
+
2290
+ const relocated = path.resolve(home, 'relocated-models')
2291
+ await fs.promises.rename(appDirectory, relocated)
2292
+ try {
2293
+ await fs.promises.symlink(relocated, appDirectory, process.platform === 'win32' ? 'junction' : 'dir')
2294
+ } catch (error) {
2295
+ t.skip(`directory links unavailable: ${error.message}`)
2296
+ return
2297
+ }
2298
+
2299
+ const result = await vault.deduplicateScope(appB.id, { batch_id: 'canonical-boundary' })
2300
+ assert.strictEqual(result.converted, 0)
2301
+ assert.strictEqual(result.stale, 1)
2302
+ assert.strictEqual((await fs.promises.stat(pending)).nlink, 1)
2303
+ assert.notStrictEqual((await fs.promises.stat(pending)).ino, (await fs.promises.stat(original)).ino)
2304
+ assert.ok(vault.registry.duplicates.has(pending))
2305
+ })
2306
+
2307
+ test('known cloud-synced homes get a dismissible dashboard warning', async () => {
2308
+ const kernel = {
2309
+ homedir: path.resolve(path.sep, 'Users', 'person', 'Library', 'Mobile Documents', 'com~apple~CloudDocs', 'pinokio'),
2310
+ platform: process.platform,
2311
+ path: (...parts) => path.resolve(path.sep, ...parts)
2312
+ }
2313
+ const vault = new Vault(kernel)
2314
+ assert.strictEqual(vault.cloudSyncProvider(), 'iCloud Drive')
2315
+ const vaultPage = await vaultPageSource()
2316
+ assert.match(vaultPage, /cloud_sync_warning/)
2317
+ assert.match(vaultPage, /btn-dismiss-cloud/)
2318
+ assert.match(vaultPage, /localStorage\.setItem/)
2319
+ })
2320
+
243
2321
  test('deduplicate batches share a batch id and are undoable as one', async () => {
244
2322
  const { home, vault } = await makeEnv()
245
2323
  const c1 = crypto.randomBytes(4096)
@@ -250,10 +2328,12 @@ describe('vault dashboard backend (phase 4)', () => {
250
2328
  await vault.adopt(a2, sha256(c2), { app: 'appA' })
251
2329
  const b1 = await writeFile(path.resolve(home, 'api', 'appB', 'm1.bin'), c1)
252
2330
  const b2 = await writeFile(path.resolve(home, 'api', 'appB', 'm2.bin'), c2)
253
- vault.registry.duplicates.set(b1, { hash: sha256(c1), size: c1.length, app: 'appB' })
254
- vault.registry.duplicates.set(b2, { hash: sha256(c2), size: c2.length, app: 'appB' })
2331
+ await vault.refreshSources()
2332
+ const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
2333
+ vault.registry.duplicates.set(b1, await duplicateEntry(b1, { hash: sha256(c1), size: c1.length, app: 'appB', source_id: appB.id }))
2334
+ vault.registry.duplicates.set(b2, await duplicateEntry(b2, { hash: sha256(c2), size: c2.length, app: 'appB', source_id: appB.id }))
255
2335
 
256
- const summary = await vault.sweeper.convertPending('appB', { batch_id: 'batch-x' })
2336
+ const summary = await vault.deduplicateScope(appB.id, { batch_id: 'batch-x' })
257
2337
  assert.strictEqual(summary.converted, 2)
258
2338
  assert.strictEqual(summary.bytes_saved, c1.length + c2.length)
259
2339
 
@@ -275,10 +2355,10 @@ describe('vault dashboard backend (phase 4)', () => {
275
2355
  const appB = vault.sources().find((item) => item.kind === 'app' && item.app === 'appB')
276
2356
  const cache = vault.sources().find((item) => item.kind === 'folder' && item.label === 'cache')
277
2357
  await vault.adopt(original, hash, { app: 'appA', source_id: appA.id })
278
- vault.registry.duplicates.set(appCopy, { hash, size: content.length, app: 'appB', source_id: appB.id })
279
- vault.registry.duplicates.set(cacheCopy, { hash, size: content.length, app: null, source_id: cache.id })
2358
+ vault.registry.duplicates.set(appCopy, await duplicateEntry(appCopy, { hash, size: content.length, app: 'appB', source_id: appB.id }))
2359
+ vault.registry.duplicates.set(cacheCopy, await duplicateEntry(cacheCopy, { hash, size: content.length, app: null, source_id: cache.id }))
280
2360
 
281
- const result = await vault.sweeper.convertPending(undefined, { scope_id: appB.id, batch_id: 'scope-app-b' })
2361
+ const result = await vault.deduplicateScope(appB.id, { batch_id: 'scope-app-b' })
282
2362
  assert.strictEqual(result.converted, 1)
283
2363
  assert.strictEqual((await fs.promises.stat(appCopy)).ino, (await fs.promises.stat(original)).ino)
284
2364
  assert.strictEqual((await fs.promises.stat(cacheCopy)).nlink, 1)
@@ -292,33 +2372,72 @@ describe('vault dashboard backend (phase 4)', () => {
292
2372
  const original = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
293
2373
  const pending = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
294
2374
  await vault.adopt(original, hash, { app: 'appA' })
295
- vault.registry.duplicates.set(pending, { hash, size: content.length, app: 'appB' })
2375
+ await vault.refreshSources()
2376
+ const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
2377
+ vault.registry.duplicates.set(pending, await duplicateEntry(pending, { hash, size: content.length, app: 'appB', source_id: appB.id }))
296
2378
  const realConvert = vault.convert.bind(vault)
297
2379
  vault.convert = async () => ({ status: 'unavailable' })
298
- const result = await vault.sweeper.convertPending('appB')
2380
+ const result = await vault.deduplicateScope(appB.id)
299
2381
  vault.convert = realConvert
300
2382
  assert.strictEqual(result.unavailable, 1)
301
2383
  assert.ok(vault.registry.duplicates.has(pending))
302
2384
  assert.strictEqual(vault.registry.links.has(pending), false)
303
2385
  })
304
2386
 
305
- test('status separates app, Pinokio folder, and external source metadata', async (t) => {
2387
+ test('deduplication rechecks a running app immediately before replacement', async () => {
306
2388
  const { home, vault } = await makeEnv()
307
- const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))
308
- homes.push(external)
2389
+ const content = crypto.randomBytes(4096)
2390
+ const hash = sha256(content)
2391
+ const original = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
2392
+ const pending = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
2393
+ await vault.refreshSources()
2394
+ const appA = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
2395
+ const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
2396
+ await vault.adopt(original, hash, { app: 'appA', source_id: appA.id })
2397
+ const pendingEntry = await duplicateEntry(pending, {
2398
+ hash, size: content.length, app: 'appB', source_id: appB.id
2399
+ })
2400
+ vault.registry.setDuplicate(pending, pendingEntry)
2401
+ vault.registry.scanIndex.set(pending, Object.assign({}, pendingEntry))
2402
+ const realVerify = vault.verifyStoreContent.bind(vault)
2403
+ vault.verifyStoreContent = async (...args) => {
2404
+ const result = await realVerify(...args)
2405
+ vault.kernel.api = {
2406
+ running: { started_during_validation: true },
2407
+ running_paths: { started_during_validation: path.resolve(home, 'api', 'appB', 'start.js') }
2408
+ }
2409
+ return result
2410
+ }
2411
+ let result
309
2412
  try {
310
- await fs.promises.symlink(external, path.resolve(home, 'api', 'linked-models'), process.platform === 'win32' ? 'junction' : 'dir')
311
- } catch (error) {
312
- t.skip(`directory links unavailable: ${error.message}`)
313
- return
2413
+ result = await vault.deduplicateScope(appB.id)
2414
+ } finally {
2415
+ vault.verifyStoreContent = realVerify
314
2416
  }
2417
+
2418
+ assert.strictEqual(result.locked, 1)
2419
+ assert.strictEqual((await fs.promises.stat(pending)).nlink, 1)
2420
+ assert.strictEqual(vault.registry.duplicates.has(pending), true)
2421
+ assert.strictEqual(vault.registry.links.has(pending), false)
2422
+ })
2423
+
2424
+ test('status separates app, Pinokio folder, and configured external source metadata', async () => {
2425
+ const { home, vault } = await makeEnv()
2426
+ const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))
2427
+ homes.push(external)
2428
+ await vault.addExternalSource(external)
315
2429
  await fs.promises.mkdir(path.resolve(home, 'api', 'local-app'), { recursive: true })
316
2430
  await fs.promises.mkdir(path.resolve(home, 'cache'), { recursive: true })
317
2431
  await vault.refreshSources()
318
2432
  const status = await vault.status()
2433
+ const canonicalExternal = await fs.promises.realpath(external)
319
2434
  const byKind = new Map(status.sources.map((source) => [source.id, source]))
320
2435
  assert.ok([...byKind.values()].some((source) => source.kind === 'app' && source.label === 'local-app' && source.parent_id === 'apps'))
321
2436
  assert.ok([...byKind.values()].some((source) => source.kind === 'folder' && source.label === 'cache' && source.parent_id === 'pinokio'))
322
- assert.ok([...byKind.values()].some((source) => source.kind === 'external' && source.label === 'linked-models' && source.parent_id === 'external'))
2437
+ assert.ok([...byKind.values()].some((source) =>
2438
+ source.kind === 'external' &&
2439
+ source.label === path.basename(canonicalExternal) &&
2440
+ source.root === canonicalExternal &&
2441
+ source.parent_id === 'external'))
323
2442
  })
324
2443
  })