pinokiod 8.0.47 → 8.0.49

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.
@@ -76,12 +76,12 @@ describe('vault dashboard backend (phase 4)', () => {
76
76
  }
77
77
 
78
78
  const accepted = await vault.perform('scan', {
79
- candidate_size: CANDIDATE_SIZE_OPTIONS[1]
79
+ candidate_size: CANDIDATE_SIZE_OPTIONS[0]
80
80
  })
81
81
  assert.deepStrictEqual(accepted, { started: true })
82
82
  assert.deepStrictEqual(startedWith, {
83
83
  scopeId: null,
84
- sizeThreshold: CANDIDATE_SIZE_OPTIONS[1]
84
+ sizeThreshold: CANDIDATE_SIZE_OPTIONS[0]
85
85
  })
86
86
 
87
87
  startedWith = null
@@ -674,7 +674,8 @@ describe('vault dashboard backend (phase 4)', () => {
674
674
  await vault.sweeper.scan()
675
675
  const status = await vault.status()
676
676
  assert.strictEqual(status.enabled, true)
677
- assert.strictEqual(status.bytes_on_disk, content.length)
677
+ assert.strictEqual(status.bytes_on_disk, content.length * 2)
678
+ assert.strictEqual(status.bytes_without_sharing, content.length * 2)
678
679
  assert.ok(status.last_scan && status.last_scan.files > 0)
679
680
  assert.strictEqual(status.excluded.length, 1)
680
681
  assert.strictEqual(status.excluded[0].size, content.length)
@@ -705,6 +706,79 @@ describe('vault dashboard backend (phase 4)', () => {
705
706
  assert.strictEqual(status.saved_by_sharing, 0)
706
707
  })
707
708
 
709
+ test('global storage metrics include scanned files below the candidate threshold', async () => {
710
+ const { home, vault } = await makeEnv()
711
+ const { content } = await makeSharedPair(home, vault)
712
+ const small = crypto.randomBytes(512)
713
+ await writeFile(path.resolve(home, 'api', 'appA', 'config.json'), small)
714
+ await vault.sweeper.scan()
715
+
716
+ const status = await vault.status()
717
+ assert.strictEqual(status.last_scan.bytes_total, (content.length * 2) + small.length)
718
+ assert.strictEqual(status.bytes_on_disk, content.length + small.length)
719
+ assert.strictEqual(status.bytes_without_sharing, (content.length * 2) + small.length)
720
+ assert.strictEqual(status.saved_by_sharing, content.length)
721
+ })
722
+
723
+ test('global storage metrics do not disappear when every scanned file is below the candidate threshold', async () => {
724
+ const { home, vault } = await makeEnv()
725
+ const first = crypto.randomBytes(511)
726
+ const second = crypto.randomBytes(257)
727
+ await writeFile(path.resolve(home, 'api', 'appA', 'config.json'), first)
728
+ await writeFile(path.resolve(home, 'api', 'appB', 'notes.txt'), second)
729
+ await vault.sweeper.scan()
730
+
731
+ const status = await vault.status()
732
+ const total = first.length + second.length
733
+ assert.strictEqual(status.last_scan.bytes_total, total)
734
+ assert.strictEqual(status.bytes_on_disk, total)
735
+ assert.strictEqual(status.bytes_without_sharing, total)
736
+ assert.strictEqual(status.saved_by_sharing, 0)
737
+ })
738
+
739
+ test('global storage metrics include below-threshold files from external locations', async (t) => {
740
+ const { home, vault } = await makeEnv()
741
+ const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-size-external-'))
742
+ homes.push(external)
743
+ const local = crypto.randomBytes(401)
744
+ const imported = crypto.randomBytes(607)
745
+ await writeFile(path.resolve(home, 'api', 'appA', 'local.json'), local)
746
+ await writeFile(path.resolve(external, 'external.json'), imported)
747
+ try {
748
+ await vault.addExternalSource(external)
749
+ } catch (error) {
750
+ t.skip(`directory links unavailable: ${error.message}`)
751
+ return
752
+ }
753
+ await vault.sweeper.scan()
754
+
755
+ const status = await vault.status()
756
+ const total = local.length + imported.length
757
+ assert.strictEqual(status.last_scan.home_bytes_total, local.length)
758
+ assert.strictEqual(status.last_scan.bytes_total, total)
759
+ assert.strictEqual(status.bytes_on_disk, total)
760
+ assert.strictEqual(status.bytes_without_sharing, total)
761
+ assert.strictEqual(status.saved_by_sharing, 0)
762
+ })
763
+
764
+ test('global storage metrics add unused managed files without double-counting scanned files', async () => {
765
+ const { home, vault } = await makeEnv()
766
+ const managed = crypto.randomBytes(4096)
767
+ const small = crypto.randomBytes(379)
768
+ const managedPath = await writeFile(path.resolve(home, 'api', 'appA', 'unused.bin'), managed)
769
+ await vault.adopt(managedPath, sha256(managed), { app: 'appA' })
770
+ await fs.promises.unlink(managedPath)
771
+ await writeFile(path.resolve(home, 'api', 'appA', 'settings.json'), small)
772
+ await vault.sweeper.scan()
773
+
774
+ const status = await vault.status()
775
+ assert.strictEqual(status.last_scan.bytes_total, small.length)
776
+ assert.strictEqual(status.reclaimable, managed.length)
777
+ assert.strictEqual(status.bytes_on_disk, managed.length + small.length)
778
+ assert.strictEqual(status.bytes_without_sharing, managed.length + small.length)
779
+ assert.strictEqual(status.saved_by_sharing, 0)
780
+ })
781
+
708
782
  test('app status exposes only that app while retaining its matching locations', async () => {
709
783
  const { home, vault } = await makeEnv()
710
784
  const content = crypto.randomBytes(4096)
@@ -1061,6 +1135,81 @@ describe('vault dashboard backend (phase 4)', () => {
1061
1135
  }
1062
1136
  })
1063
1137
 
1138
+ test('an older status response cannot replace newer scan progress', async () => {
1139
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1140
+ const publicRoot = path.resolve(__dirname, '..', 'server', 'public')
1141
+ const workspace = await ejs.renderFile(
1142
+ path.resolve(views, 'partials', 'vault_workspace.ejs'),
1143
+ { appMode: false }
1144
+ )
1145
+ const baseStatus = {
1146
+ enabled: true,
1147
+ mode: 'link',
1148
+ last_scan: null,
1149
+ bytes_on_disk: 0,
1150
+ bytes_without_sharing: 0,
1151
+ saved_by_sharing: 0,
1152
+ lifetime_bytes_saved: 0,
1153
+ pending_bytes: 0,
1154
+ reclaimable: 0,
1155
+ file_action: null,
1156
+ activity_error: null,
1157
+ cloud_sync_warning: null,
1158
+ sources: [{
1159
+ id: 'pinokio', kind: 'pinokio', label: 'Pinokio', root: '/pinokio',
1160
+ display_path: '/pinokio', parent_id: null, available: true, shareable: true
1161
+ }],
1162
+ blobs: [], duplicates: [], excluded: [], events: [], undo_batches: []
1163
+ }
1164
+ const scan = (queued, currentFile) => ({
1165
+ active: true, pending: false, phase: 'analyzing', scope_id: null,
1166
+ dirs: 4, files: 8, total_files: 8, bytes_total: 8000,
1167
+ hash_total: 937, queued, current_file: currentFile,
1168
+ current_file_bytes: 0, current_file_size: 1000,
1169
+ started: 123
1170
+ })
1171
+ const response = (data) => ({ ok: true, json: async () => data })
1172
+ let requestCount = 0
1173
+ let resolveOlder
1174
+ const olderResponse = new Promise((resolve) => { resolveOlder = resolve })
1175
+ const dom = new JSDOM(
1176
+ `<body data-platform="darwin" data-vault-mode="global">${workspace}</body>`,
1177
+ { url: 'http://localhost/vault', runScripts: 'dangerously' }
1178
+ )
1179
+ dom.window.fetch = async () => {
1180
+ requestCount += 1
1181
+ if (requestCount === 1) {
1182
+ return response(Object.assign({}, baseStatus, {
1183
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null }
1184
+ }))
1185
+ }
1186
+ if (requestCount === 2) return olderResponse
1187
+ return response(Object.assign({}, baseStatus, { scan: scan(115, 'newer.bin') }))
1188
+ }
1189
+ try {
1190
+ const formatter = await fs.promises.readFile(path.resolve(publicRoot, 'storage-size.js'), 'utf8')
1191
+ const source = await fs.promises.readFile(path.resolve(publicRoot, 'vault.js'), 'utf8')
1192
+ dom.window.eval(formatter)
1193
+ dom.window.eval(source.replace(/\nrefresh\(\)\s*$/, '\nwindow.__testVaultRefresh = refresh\nrefresh()'))
1194
+ await new Promise((resolve) => setTimeout(resolve, 25))
1195
+
1196
+ const olderRefresh = dom.window.__testVaultRefresh()
1197
+ await new Promise((resolve) => setTimeout(resolve, 0))
1198
+ const newerRefresh = dom.window.__testVaultRefresh()
1199
+ await newerRefresh
1200
+ assert.strictEqual(dom.window.document.querySelector('.vault-scan-percent').textContent, '87.7%')
1201
+ assert.match(dom.window.document.querySelector('.vault-scan-detail').textContent, /822 of 937 large files checked/)
1202
+
1203
+ resolveOlder(response(Object.assign({}, baseStatus, { scan: scan(204, 'older.bin') })))
1204
+ await olderRefresh
1205
+ assert.strictEqual(dom.window.document.querySelector('.vault-scan-percent').textContent, '87.7%')
1206
+ assert.match(dom.window.document.querySelector('.vault-scan-detail').textContent, /822 of 937 large files checked/)
1207
+ assert.doesNotMatch(dom.window.document.querySelector('.vault-scan-detail').textContent, /older\.bin/)
1208
+ } finally {
1209
+ dom.window.close()
1210
+ }
1211
+ })
1212
+
1064
1213
  test('item 16: vocabulary lint — vault page copy avoids forbidden terms', async () => {
1065
1214
  const forbidden = /hard.?link|junction|symlink|inode|\bblob\b|\bstore\b|\bdedupe\b|\bvault\b/i
1066
1215
  const vaultPage = await vaultPageSource()
@@ -1086,24 +1235,33 @@ describe('vault dashboard backend (phase 4)', () => {
1086
1235
  test('copy-mode tracking never claims physical sharing savings', async () => {
1087
1236
  const { home, vault } = await makeEnv()
1088
1237
  const content = crypto.randomBytes(4096)
1238
+ const small = crypto.randomBytes(383)
1089
1239
  const hash = sha256(content)
1090
1240
  const first = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
1091
1241
  const second = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
1242
+ await writeFile(path.resolve(home, 'api', 'appA', 'config.json'), small)
1092
1243
  const firstStat = await fs.promises.stat(first)
1093
1244
  const secondStat = await fs.promises.stat(second)
1094
1245
  vault.registry.addBlob(hash, { size: content.length })
1095
1246
  vault.registry.addLink(first, { hash, app: 'appA', dev: firstStat.dev, ino: firstStat.ino, mode: 'copy' })
1096
1247
  vault.registry.addLink(second, { hash, app: 'appB', dev: secondStat.dev, ino: secondStat.ino, mode: 'copy' })
1248
+ vault.registry.setLastScan({
1249
+ ts: Date.now(),
1250
+ files: 3,
1251
+ bytes_total: (content.length * 2) + small.length,
1252
+ home_bytes_total: (content.length * 2) + small.length
1253
+ })
1097
1254
 
1098
1255
  const status = await vault.status()
1099
- assert.strictEqual(status.bytes_on_disk, content.length * 2)
1256
+ assert.strictEqual(status.bytes_on_disk, (content.length * 2) + small.length)
1257
+ assert.strictEqual(status.bytes_without_sharing, (content.length * 2) + small.length)
1100
1258
  assert.strictEqual(status.saved_by_sharing, 0)
1101
1259
  })
1102
1260
 
1103
1261
  test('repair is advanced, metrics distinguish detected and explicit savings, and refresh retries', async () => {
1104
1262
  const vaultPage = await vaultPageSource()
1105
1263
  assert.match(vaultPage, /disk_space_saved:\s*"of disk space saved"/)
1106
- assert.match(vaultPage, /before_help:\s*"Estimated space if every app stored its own copy\."/)
1264
+ assert.match(vaultPage, /before_help:\s*"Estimated size of every scanned location if each app stored its own copy\. File Explorer may count deduplicated files differently\."/)
1107
1265
  assert.match(vaultPage, /nothing_more_to_save:\s*"Nothing else to save"/)
1108
1266
  assert.match(vaultPage, /more_can_be_saved:\s*"\{size\} more can be saved"/)
1109
1267
  assert.match(vaultPage, /fmt\(data\.saved_by_sharing\)/)
@@ -1186,7 +1344,8 @@ describe('vault dashboard backend (phase 4)', () => {
1186
1344
  assert.match(vaultPage, /pinokio:vault:reviewed-scan/)
1187
1345
  assert.match(vaultPage, /completed \|\| incomplete \|\| \(!state\.scanResult && unreviewed\)/)
1188
1346
  assert.match(vaultPage, /state\.data\.undo_batches/)
1189
- assert.match(vaultPage, /data-undo="\$\{attr\(batch\.batch_id\)\}"/)
1347
+ assert.match(vaultPage, /data-undo="\$\{attr\(item\.batch_id\)\}"/)
1348
+ assert.match(vaultPage, /data-undo="\$\{attr\(event\.batch_id\)\}"/)
1190
1349
  assert.match(vaultPage, /last_scan && data\.last_scan\.hash_failures/)
1191
1350
  assert.match(vaultPage, /scan_not_analyzed:\s*"could not be analyzed"/)
1192
1351
  assert.match(vaultPage, /\.vault-progress-bar\.determinate \{[\s\S]*?transform:\s*scaleX/)
@@ -1323,7 +1482,7 @@ describe('vault dashboard backend (phase 4)', () => {
1323
1482
  assert.match(dom.window.document.getElementById('btn-scan').textContent, /Scan this app/)
1324
1483
  const candidateSize = dom.window.document.getElementById('vault-candidate-size')
1325
1484
  assert.deepStrictEqual([...candidateSize.options].map((option) => option.textContent),
1326
- ['10 MB+', '50 MB+', '100 MB+', '500 MB+', '1 GB+'])
1485
+ ['1 MB+', '10 MB+', '50 MB+', '100 MB+', '500 MB+', '1 GB+'])
1327
1486
  assert.strictEqual(candidateSize.value, '100000000')
1328
1487
  candidateSize.value = '50000000'
1329
1488
  candidateSize.dispatchEvent(new dom.window.Event('change', { bubbles: true }))
@@ -1387,11 +1546,14 @@ describe('vault dashboard backend (phase 4)', () => {
1387
1546
  const formatter = await fs.promises.readFile(
1388
1547
  path.resolve(__dirname, '..', 'server', 'public', 'storage-size.js'), 'utf8')
1389
1548
  dom.window.eval(formatter)
1549
+ const screenshotFolderBytes = 2_002_022_055_452
1390
1550
  assert.strictEqual(dom.window.PinokioFormatStorageSize(7.05e9), '7.05 GB')
1391
1551
  assert.strictEqual(dom.window.PinokioFormatStorageSize(5.3e9), '5.3 GB')
1392
1552
  assert.strictEqual(dom.window.PinokioFormatStorageSize(1024 ** 3), '1.07 GB')
1553
+ assert.strictEqual(dom.window.PinokioFormatStorageSize(screenshotFolderBytes), '2 TB')
1393
1554
  dom.window.document.body.dataset.platform = 'win32'
1394
1555
  assert.strictEqual(dom.window.PinokioFormatStorageSize(1024 ** 3), '1 GB')
1556
+ assert.strictEqual(dom.window.PinokioFormatStorageSize(screenshotFolderBytes), '1.82 TB')
1395
1557
 
1396
1558
  const appView = await fs.promises.readFile(
1397
1559
  path.resolve(__dirname, '..', 'server', 'views', 'app.ejs'), 'utf8')
@@ -1443,7 +1605,8 @@ describe('vault dashboard backend (phase 4)', () => {
1443
1605
  assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['Before', 'After'])
1444
1606
  assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['661.75 GB', '404.8 GB'])
1445
1607
  assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:61\.17%/)
1446
- assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent, 'Estimated space if every app stored its own copy.')
1608
+ assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent,
1609
+ 'Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.')
1447
1610
  assert.strictEqual(dom.window.document.querySelector('.vault-compare-info').getAttribute('tabindex'), '0')
1448
1611
  assert.match(dom.window.document.querySelector('.vault-summary-side').textContent, /13\.31 GB more can be saved/)
1449
1612
  assert.strictEqual(dom.window.document.getElementById('btn-review-metric').textContent, 'Review files')
@@ -1451,12 +1614,21 @@ describe('vault dashboard backend (phase 4)', () => {
1451
1614
  assert.strictEqual(dom.window.document.getElementById('btn-scan').classList.contains('primary'), false)
1452
1615
  assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Pinokio folder\s*166\.22 GB/)
1453
1616
  assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Saved by your actions\s*258\.77 GB/)
1617
+ const cleanupNotice = dom.window.document.getElementById('vault-cleanup-notice')
1618
+ assert.strictEqual(cleanupNotice.classList.contains('show'), true)
1619
+ assert.match(cleanupNotice.textContent, /ready to clean up/)
1620
+ assert.match(cleanupNotice.textContent, /1 private link left/)
1621
+ dom.window.document.getElementById('btn-review-cleanup').click()
1622
+ assert.strictEqual(dom.window.document.querySelector('[data-view="reclaimable"]').classList.contains('selected'), true)
1623
+ assert.strictEqual(cleanupNotice.classList.contains('show'), false)
1624
+ dom.window.document.querySelector('[data-view="all"]').click()
1454
1625
  const viewDescriptions = {
1455
1626
  all: 'Every scanned file and its current deduplication status.',
1456
1627
  duplicates: 'Identical files waiting to be deduplicated or kept separate.',
1457
1628
  shared: 'Files that share disk storage across multiple locations.',
1629
+ tracked: 'Files with no duplicate action required.',
1458
1630
  independent: 'Duplicate files that remain as separate copies.',
1459
- reclaimable: 'Stored copies no longer used by any configured location.',
1631
+ reclaimable: 'Private links no longer used by any linked file.',
1460
1632
  activity: 'A history of scans and changes made by Save space.'
1461
1633
  }
1462
1634
  for (const [view, description] of Object.entries(viewDescriptions)) {
@@ -1472,9 +1644,10 @@ describe('vault dashboard backend (phase 4)', () => {
1472
1644
  const unusedView = dom.window.document.querySelector('[data-view="reclaimable"]')
1473
1645
  assert.strictEqual(unusedView.querySelector('.vault-nav-name').textContent, 'Unused files')
1474
1646
  unusedView.click()
1475
- assert.strictEqual(dom.window.document.getElementById('btn-reclaim-all').textContent, 'Delete all')
1476
- assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, 'Delete')
1477
- assert.match(dom.window.document.getElementById('vault-pane-footer').textContent, /Deleting them frees disk space/)
1647
+ assert.strictEqual(dom.window.document.getElementById('btn-reclaim-all').textContent, 'Clean up all')
1648
+ assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, 'Clean up')
1649
+ assert.match(dom.window.document.getElementById('vault-pane-footer').textContent,
1650
+ /Cleaning them up frees disk space/)
1478
1651
  dom.window.close()
1479
1652
  })
1480
1653
 
@@ -1517,6 +1690,7 @@ describe('vault dashboard backend (phase 4)', () => {
1517
1690
  ['10 GB', '7 GB'])
1518
1691
  assert.match(dom.window.document.querySelector('.vault-summary-side').textContent,
1519
1692
  /1 GB more can be saved.*Not scanned yet/)
1693
+ assert.strictEqual(dom.window.document.getElementById('vault-cleanup-notice').classList.contains('show'), false)
1520
1694
  dom.window.close()
1521
1695
  })
1522
1696
 
@@ -1549,8 +1723,8 @@ describe('vault dashboard backend (phase 4)', () => {
1549
1723
  assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.can_free, ""\]/)
1550
1724
  assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.last_scanned, ""\]/)
1551
1725
  assert.match(source, /reclaimable:\s*"Unused files"/)
1552
- assert.match(source, /reclaim:\s*"Delete"/)
1553
- assert.match(source, /reclaim_all:\s*"Delete all"/)
1726
+ assert.match(source, /reclaim:\s*"Clean up"/)
1727
+ assert.match(source, /reclaim_all:\s*"Clean up all"/)
1554
1728
  assert.match(source, /data-sort-size/)
1555
1729
  assert.match(source, /data-display-mode="folders"/)
1556
1730
  assert.match(source, /data-display-mode="files"/)
@@ -1853,6 +2027,244 @@ describe('vault dashboard backend (phase 4)', () => {
1853
2027
  }
1854
2028
  })
1855
2029
 
2030
+ test('No action needed is a counted, filtered, paginated file view', async () => {
2031
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2032
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2033
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
2034
+ })
2035
+ const dom = new JSDOM(html, {
2036
+ url: 'http://localhost/vault/app/appB',
2037
+ runScripts: 'dangerously'
2038
+ })
2039
+ const blobs = Array.from({ length: 501 }, (_, index) => {
2040
+ const name = `tracked-${String(index).padStart(4, '0')}.bin`
2041
+ return {
2042
+ hash: index.toString(16).padStart(64, '0'),
2043
+ size: 1024 + index,
2044
+ orphan: false,
2045
+ nlink: 2,
2046
+ names: [{
2047
+ path: `/pinokio/api/appB/${name}`,
2048
+ relative_path: name,
2049
+ source_id: 'app:appB',
2050
+ source_label: 'appB',
2051
+ mode: 'link'
2052
+ }]
2053
+ }
2054
+ })
2055
+ const status = {
2056
+ enabled: true,
2057
+ mode: 'link',
2058
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2059
+ last_scan: { ts: Date.now(), bytes_total: 1024 * 502 },
2060
+ tracked_bytes: 1024 * 502,
2061
+ effective_bytes: 1024 * 502,
2062
+ shared_bytes: 0,
2063
+ pending_bytes: 1024,
2064
+ activity_error: null,
2065
+ cloud_sync_warning: null,
2066
+ sources: [{
2067
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
2068
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
2069
+ }],
2070
+ blobs,
2071
+ duplicates: [{
2072
+ path: '/pinokio/api/appB/duplicate.bin',
2073
+ relative_path: 'duplicate.bin',
2074
+ source_id: 'app:appB',
2075
+ source_label: 'appB',
2076
+ size: 1024,
2077
+ shareable: true,
2078
+ match: { path: '/pinokio/api/appA/duplicate.bin' }
2079
+ }],
2080
+ excluded: [],
2081
+ events: [],
2082
+ undo_batches: []
2083
+ }
2084
+ dom.window.fetch = async () => ({ ok: true, json: async () => status })
2085
+ await runVaultScript(dom)
2086
+ await new Promise((resolve) => setTimeout(resolve, 25))
2087
+
2088
+ const trackedView = dom.window.document.querySelector('[data-view="tracked"]')
2089
+ assert.strictEqual(trackedView.querySelector('.vault-nav-name').textContent, 'No action needed')
2090
+ assert.strictEqual(trackedView.querySelector('.vault-nav-count').textContent, '501')
2091
+ trackedView.click()
2092
+
2093
+ const rows = () => [...dom.window.document.querySelectorAll('.vault-table .vault-file-row')]
2094
+ assert.strictEqual(rows().length, 500)
2095
+ assert.strictEqual(dom.window.document.getElementById('vault-search').placeholder,
2096
+ 'Search files with no action needed')
2097
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 501')
2098
+ assert.doesNotMatch(dom.window.document.getElementById('vault-table-wrap').textContent, /duplicate\.bin/)
2099
+
2100
+ dom.window.document.querySelector('[data-page="next"]').click()
2101
+ assert.strictEqual(rows().length, 1)
2102
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'tracked-0500.bin')
2103
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–501 of 501')
2104
+ dom.window.close()
2105
+ })
2106
+
2107
+ test('Activity is paginated without repeating a batch undo action', async () => {
2108
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2109
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2110
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
2111
+ })
2112
+ const dom = new JSDOM(html, {
2113
+ url: 'http://localhost/vault/app/appB',
2114
+ runScripts: 'dangerously'
2115
+ })
2116
+ const events = Array.from({ length: 501 }, (_, index) => ({
2117
+ kind: 'convert',
2118
+ path: `/pinokio/api/appB/event-${String(index).padStart(4, '0')}.bin`,
2119
+ relative_path: `event-${String(index).padStart(4, '0')}.bin`,
2120
+ source_id: 'app:appB',
2121
+ source_label: 'appB',
2122
+ batch_id: 'batch-large',
2123
+ undoable: index !== 0,
2124
+ bytes_saved: 1024 + index,
2125
+ ts: Date.now() - index
2126
+ }))
2127
+ const status = {
2128
+ enabled: true,
2129
+ mode: 'link',
2130
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2131
+ last_scan: { ts: Date.now(), bytes_total: 1024 * 501 },
2132
+ tracked_bytes: 0,
2133
+ effective_bytes: 0,
2134
+ shared_bytes: 0,
2135
+ pending_bytes: 0,
2136
+ activity_error: null,
2137
+ cloud_sync_warning: null,
2138
+ sources: [{
2139
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
2140
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
2141
+ }],
2142
+ blobs: [],
2143
+ duplicates: [],
2144
+ excluded: [],
2145
+ events,
2146
+ undo_batches: [{
2147
+ batch_id: 'batch-large',
2148
+ files: events.length,
2149
+ bytes: events.reduce((sum, event) => sum + event.bytes_saved, 0),
2150
+ ts: events[0].ts
2151
+ }, {
2152
+ batch_id: 'batch-compacted',
2153
+ files: 2,
2154
+ bytes: 4096,
2155
+ ts: events[events.length - 1].ts - 1
2156
+ }]
2157
+ }
2158
+ dom.window.fetch = async () => ({ ok: true, json: async () => status })
2159
+ await runVaultScript(dom)
2160
+ await new Promise((resolve) => setTimeout(resolve, 25))
2161
+
2162
+ const activityView = dom.window.document.querySelector('[data-view="activity"]')
2163
+ assert.strictEqual(activityView.querySelector('.vault-nav-count').textContent, '502')
2164
+ activityView.click()
2165
+
2166
+ const rows = () => [...dom.window.document.querySelectorAll('.vault-table.activity .vault-file-row')]
2167
+ assert.strictEqual(rows().length, 500)
2168
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-large"]').length, 1)
2169
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-compacted"]').length, 1)
2170
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 502')
2171
+
2172
+ dom.window.document.querySelector('[data-page="next"]').click()
2173
+ assert.strictEqual(rows().length, 2)
2174
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-large"]').length, 0)
2175
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-compacted"]').length, 0)
2176
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–502 of 502')
2177
+ dom.window.close()
2178
+ })
2179
+
2180
+ test('large file lists render bounded pages without narrowing Deduplicate all', async () => {
2181
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2182
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2183
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
2184
+ })
2185
+ const dom = new JSDOM(html, {
2186
+ url: 'http://localhost/vault/app/appB',
2187
+ runScripts: 'dangerously'
2188
+ })
2189
+ const actions = []
2190
+ const duplicates = Array.from({ length: 501 }, (_, index) => {
2191
+ const name = `file-${String(index).padStart(4, '0')}.bin`
2192
+ return {
2193
+ path: `/pinokio/api/appB/${name}`,
2194
+ relative_path: name,
2195
+ source_id: 'app:appB',
2196
+ source_label: 'appB',
2197
+ size: 1024 + index,
2198
+ shareable: true,
2199
+ match: { path: `/pinokio/api/appA/${name}` }
2200
+ }
2201
+ })
2202
+ const status = {
2203
+ enabled: true,
2204
+ mode: 'link',
2205
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2206
+ last_scan: { ts: Date.now(), bytes_total: 1024 * 501 },
2207
+ tracked_bytes: 1024 * 501,
2208
+ effective_bytes: 1024 * 501,
2209
+ shared_bytes: 0,
2210
+ pending_bytes: duplicates.reduce((sum, item) => sum + item.size, 0),
2211
+ activity_error: null,
2212
+ cloud_sync_warning: null,
2213
+ sources: [{
2214
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
2215
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
2216
+ }],
2217
+ blobs: [],
2218
+ duplicates,
2219
+ excluded: [],
2220
+ events: [],
2221
+ undo_batches: []
2222
+ }
2223
+ dom.window.fetch = async (url, options = {}) => {
2224
+ if (options.method === 'POST') {
2225
+ actions.push(JSON.parse(options.body))
2226
+ return { ok: true, json: async () => ({ converted: 501, bytes_saved: status.pending_bytes }) }
2227
+ }
2228
+ return { ok: true, json: async () => status }
2229
+ }
2230
+ await runVaultScript(dom)
2231
+ await new Promise((resolve) => setTimeout(resolve, 25))
2232
+
2233
+ dom.window.document.querySelector('[data-view="duplicates"]').click()
2234
+ const rows = () => [...dom.window.document.querySelectorAll('.vault-table.matches .vault-file-row')]
2235
+ let bulk = dom.window.document.querySelector('[data-deduplicate-all="duplicates"]')
2236
+ assert.strictEqual(rows().length, 500)
2237
+ assert.strictEqual(bulk.textContent, 'Deduplicate 501 files')
2238
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 501')
2239
+ assert.strictEqual(dom.window.document.querySelector('[data-page="previous"]').disabled, true)
2240
+ assert.strictEqual(dom.window.document.querySelector('[data-page="next"]').disabled, false)
2241
+
2242
+ dom.window.document.querySelector('[data-page="next"]').click()
2243
+ assert.strictEqual(rows().length, 1)
2244
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'file-0500.bin')
2245
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–501 of 501')
2246
+ assert.strictEqual(dom.window.document.querySelector('[data-page="previous"]').disabled, false)
2247
+ assert.strictEqual(dom.window.document.querySelector('[data-page="next"]').disabled, true)
2248
+
2249
+ bulk = dom.window.document.querySelector('[data-deduplicate-all="duplicates"]')
2250
+ assert.strictEqual(bulk.textContent, 'Deduplicate 501 files')
2251
+ bulk.click()
2252
+ await new Promise((resolve) => setTimeout(resolve, 25))
2253
+ assert.deepStrictEqual(actions[0], {
2254
+ action: 'deduplicate',
2255
+ selection: 'duplicates',
2256
+ scope_id: 'app:appB'
2257
+ })
2258
+
2259
+ const search = dom.window.document.getElementById('vault-search')
2260
+ search.value = 'file-0001.bin'
2261
+ search.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
2262
+ assert.strictEqual(rows().length, 1)
2263
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'file-0001.bin')
2264
+ assert.strictEqual(dom.window.document.querySelector('.vault-pagination'), null)
2265
+ dom.window.close()
2266
+ })
2267
+
1856
2268
  test('app inventory uses explicit sharing actions without ambiguous switches', async () => {
1857
2269
  const views = path.resolve(__dirname, '..', 'server', 'views')
1858
2270
  const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
@@ -2060,6 +2472,64 @@ describe('vault dashboard backend (phase 4)', () => {
2060
2472
  dom.window.close()
2061
2473
  })
2062
2474
 
2475
+ test('external file rows and expanded matches show the full target path', async () => {
2476
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2477
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2478
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'external:api'
2479
+ })
2480
+ const dom = new JSDOM(html, { url: 'http://localhost/vault/app/external%3Aapi', runScripts: 'dangerously' })
2481
+ const targetRoot = '/Volumes/Models/api'
2482
+ const relativePath = 'ideogram/app/comfy_models/model.safetensors'
2483
+ const externalPath = `${targetRoot}/${relativePath}`
2484
+ const status = {
2485
+ enabled: true,
2486
+ mode: 'link',
2487
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2488
+ last_scan: { ts: Date.now(), bytes_total: 4096 },
2489
+ tracked_bytes: 4096,
2490
+ effective_bytes: 2048,
2491
+ shared_bytes: 4096,
2492
+ pending_bytes: 0,
2493
+ activity_error: null,
2494
+ cloud_sync_warning: null,
2495
+ sources: [{
2496
+ id: 'external:api', kind: 'external', label: 'api', root: targetRoot,
2497
+ display_path: '/pinokio/vault/sources/api', target_path: targetRoot,
2498
+ parent_id: null, available: true, shareable: true
2499
+ }],
2500
+ blobs: [{
2501
+ hash: 'a'.repeat(64), size: 4096, orphan: false, nlink: 3,
2502
+ names: [
2503
+ {
2504
+ path: externalPath, relative_path: relativePath,
2505
+ source_id: 'external:api', source_label: 'api', mode: 'link'
2506
+ },
2507
+ {
2508
+ path: `/pinokio/api/appA/${relativePath}`, relative_path: relativePath,
2509
+ source_id: 'app:appA', source_label: 'appA', mode: 'link'
2510
+ }
2511
+ ]
2512
+ }],
2513
+ duplicates: [], excluded: [], events: [], undo_batches: []
2514
+ }
2515
+ dom.window.fetch = async () => ({ ok: true, json: async () => status })
2516
+ await runVaultScript(dom)
2517
+ await new Promise((resolve) => setTimeout(resolve, 25))
2518
+
2519
+ dom.window.document.querySelector('[data-view="shared"]').click()
2520
+ assert.strictEqual(
2521
+ dom.window.document.querySelector('.vault-flat-location').textContent,
2522
+ externalPath
2523
+ )
2524
+ dom.window.document.querySelector('[data-expand-file]').click()
2525
+ assert.deepStrictEqual(
2526
+ [...dom.window.document.querySelectorAll('.vault-location-detail span')]
2527
+ .map((node) => node.textContent),
2528
+ [externalPath, `appA / ${relativePath}`]
2529
+ )
2530
+ dom.window.close()
2531
+ })
2532
+
2063
2533
  test('vault route provisions the dev requirements needed by its folder picker', async () => {
2064
2534
  const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
2065
2535
  const routeStart = serverSource.indexOf('this.app.get("/vault"')