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.
- package/kernel/api/hf/index.js +2 -2
- package/kernel/api/index.js +12 -3
- package/kernel/connect/index.js +2 -2
- package/kernel/connect/providers/huggingface/index.js +14 -5
- package/kernel/index.js +14 -2
- package/kernel/shell.js +3 -2
- package/kernel/shells.js +6 -0
- package/kernel/vault/constants.js +13 -0
- package/kernel/vault/hash_worker.js +9 -2
- package/kernel/vault/index.js +1856 -316
- package/kernel/vault/registry.js +526 -52
- package/kernel/vault/snapshot.js +29 -0
- package/kernel/vault/sweeper.js +480 -210
- package/kernel/vault/walker.js +142 -0
- package/package.json +1 -1
- package/server/index.js +79 -90
- package/server/lib/privacy_filter_cache.js +4 -1
- package/server/public/storage-size.js +12 -0
- package/server/public/style.css +13 -0
- package/server/public/tab-link-popover.js +3 -0
- package/server/public/vault-nav.js +96 -0
- package/server/public/vault.css +1079 -0
- package/server/public/vault.js +1835 -0
- package/server/views/app.ejs +93 -16
- package/server/views/connect/huggingface.ejs +7 -9
- package/server/views/partials/main_sidebar.ejs +6 -1
- package/server/views/partials/vault_workspace.ejs +55 -0
- package/server/views/vault.ejs +5 -1532
- package/server/views/vault_app.ejs +22 -0
- package/test/hf-api.test.js +4 -2
- package/test/huggingface-connect.test.js +7 -11
- package/test/huggingface-token-validity.test.js +135 -0
- package/test/plugin-action-functions.test.js +19 -0
- package/test/privacy-filter-cache.test.js +1 -0
- package/test/vault-engine.test.js +1276 -26
- package/test/vault-sweep.test.js +1043 -35
- package/test/vault-ui.test.js +2184 -65
package/test/vault-ui.test.js
CHANGED
|
@@ -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('
|
|
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.
|
|
70
|
-
assert.
|
|
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
|
-
//
|
|
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
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
await
|
|
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('
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
174
|
-
assert.match(vaultPage, /
|
|
175
|
-
assert.match(vaultPage, /
|
|
176
|
-
assert.match(vaultPage, /
|
|
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
|
|
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,
|
|
198
|
-
assert.match(vaultPage, /\.vault-
|
|
199
|
-
assert.match(vaultPage,
|
|
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" && !
|
|
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, /
|
|
1012
|
+
assert.match(vaultPage, /const counting = scanPhase === "counting"/)
|
|
210
1013
|
assert.match(vaultPage, /const hashTotal = scan\.hash_total/)
|
|
211
|
-
assert.match(vaultPage, /const
|
|
212
|
-
assert.match(vaultPage, /
|
|
213
|
-
assert.match(vaultPage,
|
|
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]*?
|
|
216
|
-
assert.
|
|
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.
|
|
254
|
-
vault.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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('
|
|
2387
|
+
test('deduplication rechecks a running app immediately before replacement', async () => {
|
|
306
2388
|
const { home, vault } = await makeEnv()
|
|
307
|
-
const
|
|
308
|
-
|
|
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
|
|
311
|
-
}
|
|
312
|
-
|
|
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) =>
|
|
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
|
})
|