pinokiod 8.0.72 → 8.0.74
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.
|
@@ -52,11 +52,11 @@ class AutomaticScans {
|
|
|
52
52
|
this.manualDepth = 0
|
|
53
53
|
this.observedApps = new Set()
|
|
54
54
|
this.changedPaths = new Map()
|
|
55
|
-
this.changedPathIndex = new Map()
|
|
56
55
|
this.pendingStops = new Map()
|
|
57
56
|
this.lifecycleWork = new Set()
|
|
58
57
|
this.stopSettleMs = STOP_SETTLE_MS
|
|
59
58
|
this.drainQueued = false
|
|
59
|
+
this.drainRequested = false
|
|
60
60
|
this.waitingFor = null
|
|
61
61
|
this.listeners = new Set()
|
|
62
62
|
this.appTransitions = new Map()
|
|
@@ -137,7 +137,6 @@ class AutomaticScans {
|
|
|
137
137
|
}
|
|
138
138
|
this.observedApps.clear()
|
|
139
139
|
this.changedPaths.clear()
|
|
140
|
-
this.changedPathIndex.clear()
|
|
141
140
|
if (watcherError) throw watcherError
|
|
142
141
|
}
|
|
143
142
|
|
|
@@ -150,125 +149,135 @@ class AutomaticScans {
|
|
|
150
149
|
})
|
|
151
150
|
return
|
|
152
151
|
}
|
|
153
|
-
|
|
152
|
+
const watcherEvents = Array.isArray(events) ? events : []
|
|
153
|
+
if (!watcherEvents.some((event) => event && event.type === "delete")) {
|
|
154
|
+
for (const event of watcherEvents) {
|
|
155
|
+
if (event && ["create", "update"].includes(event.type)) {
|
|
156
|
+
this.recordChangedPath(event.path)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const batches = new Map()
|
|
162
|
+
for (let index = 0; index < watcherEvents.length; index++) {
|
|
163
|
+
const event = watcherEvents[index]
|
|
154
164
|
if (!event || !["create", "update", "delete"].includes(event.type)) {
|
|
155
165
|
continue
|
|
156
166
|
}
|
|
157
|
-
|
|
158
|
-
|
|
167
|
+
const target = this.changedPathTarget(
|
|
168
|
+
event.path, event.type === "delete")
|
|
169
|
+
if (!target) continue
|
|
170
|
+
let batch = batches.get(target.app)
|
|
171
|
+
if (!batch) {
|
|
172
|
+
batch = {
|
|
173
|
+
root: target.root,
|
|
174
|
+
updates: new Map(),
|
|
175
|
+
deletes: new Map()
|
|
176
|
+
}
|
|
177
|
+
batches.set(target.app, batch)
|
|
178
|
+
}
|
|
179
|
+
const key = pathKey(target.path)
|
|
180
|
+
if (event.type === "delete") batch.deletes.set(key, index)
|
|
181
|
+
else batch.updates.set(key, { index, path: target.path })
|
|
182
|
+
}
|
|
183
|
+
for (const [app, batch] of batches) {
|
|
184
|
+
this.applyChangedPathBatch(app, batch)
|
|
159
185
|
}
|
|
160
186
|
}
|
|
161
187
|
|
|
162
|
-
|
|
188
|
+
changedPathTarget(filePath, allowRoot = false) {
|
|
163
189
|
if (this.disposed || !this.supported ||
|
|
164
190
|
typeof filePath !== "string" || !filePath) {
|
|
165
|
-
return
|
|
191
|
+
return null
|
|
166
192
|
}
|
|
167
193
|
const app = this.appForLaunchPath(filePath)
|
|
168
|
-
if (!app || !this.observedApps.has(app)) return
|
|
169
|
-
const
|
|
194
|
+
if (!app || !this.observedApps.has(app)) return null
|
|
195
|
+
const root = path.resolve(this.vault.kernel.homedir, "api", app)
|
|
170
196
|
const resolved = path.resolve(filePath)
|
|
171
|
-
if (resolved ===
|
|
197
|
+
if ((!allowRoot && resolved === root) || !inside(root, resolved)) return null
|
|
198
|
+
return { app, root, path: resolved }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
recordChangedPath(filePath) {
|
|
202
|
+
const target = this.changedPathTarget(filePath)
|
|
203
|
+
if (!target) return false
|
|
204
|
+
const { app, path: resolved } = target
|
|
172
205
|
let paths = this.changedPaths.get(app)
|
|
173
206
|
if (!paths) {
|
|
174
207
|
paths = new Set()
|
|
175
208
|
this.changedPaths.set(app, paths)
|
|
176
209
|
}
|
|
177
|
-
let index = this.changedPathIndex.get(app)
|
|
178
|
-
if (!index) {
|
|
179
|
-
index = { exact: new Map(), descendants: new Map() }
|
|
180
|
-
this.changedPathIndex.set(app, index)
|
|
181
|
-
}
|
|
182
|
-
const key = pathKey(resolved)
|
|
183
|
-
if (index.exact.has(key)) return true
|
|
184
210
|
paths.add(resolved)
|
|
185
|
-
index.exact.set(key, resolved)
|
|
186
|
-
this.addChangedPathToIndex(index.descendants, appRoot, resolved)
|
|
187
211
|
return true
|
|
188
212
|
}
|
|
189
213
|
|
|
190
|
-
|
|
191
|
-
const rootKey = pathKey(
|
|
192
|
-
|
|
193
|
-
let
|
|
214
|
+
lastDeleteIndex(root, filePath, deletes) {
|
|
215
|
+
const rootKey = pathKey(root)
|
|
216
|
+
let current = path.resolve(filePath)
|
|
217
|
+
let latest = -1
|
|
194
218
|
while (true) {
|
|
195
219
|
const key = pathKey(current)
|
|
196
|
-
|
|
197
|
-
if (
|
|
220
|
+
const index = deletes.get(key)
|
|
221
|
+
if (index !== undefined && index > latest) latest = index
|
|
222
|
+
if (key === rootKey) return latest
|
|
198
223
|
current = path.dirname(current)
|
|
199
224
|
}
|
|
200
225
|
}
|
|
201
226
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
descendants.add(filePath)
|
|
227
|
+
applyChangedPathBatch(app, { root, updates, deletes }) {
|
|
228
|
+
const paths = this.changedPaths.get(app)
|
|
229
|
+
if (!deletes.size) {
|
|
230
|
+
const collected = paths || new Set()
|
|
231
|
+
for (const update of updates.values()) collected.add(update.path)
|
|
232
|
+
if (collected.size) this.changedPaths.set(app, collected)
|
|
233
|
+
return
|
|
210
234
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
235
|
+
const retained = new Set()
|
|
236
|
+
if (paths) {
|
|
237
|
+
for (const candidate of paths) {
|
|
238
|
+
const key = pathKey(candidate)
|
|
239
|
+
const update = updates.get(key)
|
|
240
|
+
if (this.lastDeleteIndex(root, candidate, deletes) >
|
|
241
|
+
(update ? update.index : -1) || retained.has(key)) {
|
|
242
|
+
paths.delete(candidate)
|
|
243
|
+
} else {
|
|
244
|
+
retained.add(key)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
220
247
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
248
|
+
let collected = paths
|
|
249
|
+
for (const [key, update] of updates) {
|
|
250
|
+
if (update.index <= this.lastDeleteIndex(root, update.path, deletes) ||
|
|
251
|
+
retained.has(key)) continue
|
|
252
|
+
if (!collected) collected = new Set()
|
|
253
|
+
collected.add(update.path)
|
|
254
|
+
retained.add(key)
|
|
227
255
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const paths = this.changedPaths.get(app)
|
|
231
|
-
if (!paths) return false
|
|
232
|
-
const resolved = path.resolve(filePath)
|
|
233
|
-
const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
|
|
234
|
-
const index = this.changedPathIndex.get(app)
|
|
235
|
-
if (!index) return false
|
|
236
|
-
const key = pathKey(resolved)
|
|
237
|
-
const exact = index.exact.get(key)
|
|
238
|
-
const descendants = index.descendants.get(key)
|
|
239
|
-
if (!exact && !descendants) return false
|
|
240
|
-
const matches = new Set(descendants || [])
|
|
241
|
-
if (exact) matches.add(exact)
|
|
242
|
-
for (const candidate of matches) {
|
|
243
|
-
paths.delete(candidate)
|
|
244
|
-
this.removeChangedPathFromIndex(index, appRoot, candidate)
|
|
245
|
-
}
|
|
246
|
-
if (!paths.size) this.discardChangedPaths(app)
|
|
247
|
-
return true
|
|
256
|
+
if (collected && collected.size) this.changedPaths.set(app, collected)
|
|
257
|
+
else this.changedPaths.delete(app)
|
|
248
258
|
}
|
|
249
259
|
|
|
250
260
|
discardChangedPaths(app) {
|
|
251
261
|
this.changedPaths.delete(app)
|
|
252
|
-
this.changedPathIndex.delete(app)
|
|
253
262
|
}
|
|
254
263
|
|
|
255
264
|
consumeChangedPaths(app, paths) {
|
|
256
265
|
const collected = this.changedPaths.get(app)
|
|
257
266
|
if (!collected) return
|
|
258
|
-
const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
|
|
259
|
-
const index = this.changedPathIndex.get(app)
|
|
260
267
|
for (const filePath of paths || []) {
|
|
261
|
-
|
|
262
|
-
if (!collected.delete(resolved) || !index) continue
|
|
263
|
-
this.removeChangedPathFromIndex(index, appRoot, resolved)
|
|
268
|
+
collected.delete(path.resolve(filePath))
|
|
264
269
|
}
|
|
265
270
|
if (!collected.size) this.discardChangedPaths(app)
|
|
266
271
|
}
|
|
267
272
|
|
|
268
273
|
async candidateThreshold() {
|
|
269
|
-
const
|
|
270
|
-
|
|
274
|
+
const cached = this.vault.lastScanCache &&
|
|
275
|
+
typeof this.vault.lastScanCache.get === "function"
|
|
276
|
+
? this.vault.lastScanCache.get("")
|
|
271
277
|
: null
|
|
278
|
+
const scan = cached || (this.vault.registry
|
|
279
|
+
? await this.vault.registry.scanFor()
|
|
280
|
+
: null)
|
|
272
281
|
const threshold = scan ? Number(scan.candidate_min_bytes) : NaN
|
|
273
282
|
return Number.isFinite(threshold) && threshold >= 0
|
|
274
283
|
? threshold
|
|
@@ -805,24 +814,38 @@ class AutomaticScans {
|
|
|
805
814
|
}
|
|
806
815
|
|
|
807
816
|
schedule() {
|
|
808
|
-
if (this.disposed
|
|
817
|
+
if (this.disposed) return
|
|
818
|
+
if (this.drainQueued) {
|
|
819
|
+
this.drainRequested = true
|
|
820
|
+
return
|
|
821
|
+
}
|
|
809
822
|
this.drainQueued = true
|
|
810
823
|
queueMicrotask(() => {
|
|
811
|
-
|
|
812
|
-
|
|
824
|
+
const pending = (async () => {
|
|
825
|
+
do {
|
|
826
|
+
this.drainRequested = false
|
|
827
|
+
await this.drain()
|
|
828
|
+
} while (!this.disposed && this.drainRequested)
|
|
829
|
+
})()
|
|
813
830
|
this.lifecycleWork.add(pending)
|
|
814
831
|
pending.then(
|
|
815
|
-
() =>
|
|
816
|
-
|
|
832
|
+
() => {
|
|
833
|
+
this.lifecycleWork.delete(pending)
|
|
834
|
+
this.drainQueued = false
|
|
835
|
+
if (this.drainRequested) this.schedule()
|
|
836
|
+
},
|
|
837
|
+
(error) => {
|
|
838
|
+
this.lifecycleWork.delete(pending)
|
|
839
|
+
this.drainQueued = false
|
|
840
|
+
this.log("error", {
|
|
841
|
+
stage: "queue",
|
|
842
|
+
message: error && error.message ? error.message : String(error)
|
|
843
|
+
})
|
|
844
|
+
console.warn("Automatic Disk Saver check failed:",
|
|
845
|
+
error && error.message ? error.message : error)
|
|
846
|
+
if (this.drainRequested) this.schedule()
|
|
847
|
+
}
|
|
817
848
|
)
|
|
818
|
-
pending.catch((error) => {
|
|
819
|
-
this.log("error", {
|
|
820
|
-
stage: "queue",
|
|
821
|
-
message: error && error.message ? error.message : String(error)
|
|
822
|
-
})
|
|
823
|
-
console.warn("Automatic Disk Saver check failed:",
|
|
824
|
-
error && error.message ? error.message : error)
|
|
825
|
-
})
|
|
826
849
|
})
|
|
827
850
|
}
|
|
828
851
|
|
|
@@ -912,10 +935,11 @@ class AutomaticScans {
|
|
|
912
935
|
if (reusable) {
|
|
913
936
|
counts.hash_reuses += 1
|
|
914
937
|
memory.set(key, reusable)
|
|
915
|
-
|
|
938
|
+
const verifiedEntry = Object.assign({}, currentEntry, {
|
|
916
939
|
hash: reusable
|
|
917
|
-
})
|
|
918
|
-
|
|
940
|
+
})
|
|
941
|
+
verifiedHashes.set(pathKey(entry.path), verifiedEntry)
|
|
942
|
+
return verifiedEntry
|
|
919
943
|
}
|
|
920
944
|
let verified
|
|
921
945
|
try {
|
|
@@ -942,10 +966,11 @@ class AutomaticScans {
|
|
|
942
966
|
counts.hashed += 1
|
|
943
967
|
counts.hash_bytes += Math.max(0, Number(verified.result.size) || 0)
|
|
944
968
|
memory.set(key, verified.result.hash)
|
|
945
|
-
|
|
969
|
+
const verifiedEntry = Object.assign({}, currentEntry, {
|
|
946
970
|
hash: verified.result.hash
|
|
947
|
-
})
|
|
948
|
-
|
|
971
|
+
})
|
|
972
|
+
verifiedHashes.set(pathKey(entry.path), verifiedEntry)
|
|
973
|
+
return verifiedEntry
|
|
949
974
|
}
|
|
950
975
|
|
|
951
976
|
async runPrecheck(active) {
|
|
@@ -972,7 +997,6 @@ class AutomaticScans {
|
|
|
972
997
|
const paths = [...new Set(active.paths || [])].filter((filePath) =>
|
|
973
998
|
typeof filePath === "string" && inside(root, filePath))
|
|
974
999
|
const verifiedHashes = new Map()
|
|
975
|
-
await this.vault.registry.beginAutomaticPrecheck(app)
|
|
976
1000
|
this.log("check-started", {
|
|
977
1001
|
app,
|
|
978
1002
|
root,
|
|
@@ -980,6 +1004,7 @@ class AutomaticScans {
|
|
|
980
1004
|
changed_paths: paths.length,
|
|
981
1005
|
policy: "metadata-prefilter-sha256"
|
|
982
1006
|
})
|
|
1007
|
+
await this.vault.registry.beginAutomaticPrecheck(app)
|
|
983
1008
|
try {
|
|
984
1009
|
this.checkpoint(active)
|
|
985
1010
|
const metadataOptions = {
|
|
@@ -1042,7 +1067,6 @@ class AutomaticScans {
|
|
|
1042
1067
|
const preparePeers = () => {
|
|
1043
1068
|
if (!group || group.unmatched) return
|
|
1044
1069
|
group.unmatched = new Map()
|
|
1045
|
-
group.unmatchedIdentities = new Map()
|
|
1046
1070
|
for (const [hash, value] of group.candidatesByHash) {
|
|
1047
1071
|
if (value.identities.size > 1) {
|
|
1048
1072
|
for (const candidate of value.candidates) {
|
|
@@ -1054,9 +1078,6 @@ class AutomaticScans {
|
|
|
1054
1078
|
identity,
|
|
1055
1079
|
candidates: value.candidates
|
|
1056
1080
|
})
|
|
1057
|
-
group.unmatchedIdentities.set(
|
|
1058
|
-
identity,
|
|
1059
|
-
(group.unmatchedIdentities.get(identity) || 0) + 1)
|
|
1060
1081
|
}
|
|
1061
1082
|
}
|
|
1062
1083
|
}
|
|
@@ -1085,47 +1106,39 @@ class AutomaticScans {
|
|
|
1085
1106
|
candidatesByHash: new Map(),
|
|
1086
1107
|
matched: new Set(),
|
|
1087
1108
|
unmatched: null,
|
|
1088
|
-
unmatchedIdentities: null,
|
|
1089
1109
|
memory: new Map()
|
|
1090
1110
|
}
|
|
1091
1111
|
}
|
|
1092
1112
|
if (entry.kind === "changed") {
|
|
1093
1113
|
const candidate = entry
|
|
1094
1114
|
this.checkpoint(active)
|
|
1095
|
-
const
|
|
1115
|
+
const verified = await this.verifiedAutomaticHash(
|
|
1096
1116
|
active, candidate, group.memory, verifiedHashes, counts)
|
|
1097
|
-
if (!
|
|
1117
|
+
if (!verified) continue
|
|
1118
|
+
const hash = verified.hash
|
|
1098
1119
|
let value = group.candidatesByHash.get(hash)
|
|
1099
1120
|
if (!value) {
|
|
1100
1121
|
value = { candidates: [], identities: new Set() }
|
|
1101
1122
|
group.candidatesByHash.set(hash, value)
|
|
1102
1123
|
}
|
|
1103
|
-
value.candidates.push(
|
|
1104
|
-
value.identities.add(this.automaticIdentityKey(
|
|
1124
|
+
value.candidates.push(verified)
|
|
1125
|
+
value.identities.add(this.automaticIdentityKey(verified))
|
|
1105
1126
|
continue
|
|
1106
1127
|
}
|
|
1107
1128
|
preparePeers()
|
|
1108
1129
|
if (!group.unmatched.size) continue
|
|
1109
1130
|
this.checkpoint(active)
|
|
1110
|
-
const
|
|
1111
|
-
if (group.unmatchedIdentities.size === 1 &&
|
|
1112
|
-
group.unmatchedIdentities.has(identity)) continue
|
|
1113
|
-
const hash = await this.verifiedAutomaticHash(
|
|
1131
|
+
const verified = await this.verifiedAutomaticHash(
|
|
1114
1132
|
active, entry, group.memory, verifiedHashes, counts)
|
|
1115
|
-
const wanted =
|
|
1133
|
+
const wanted = verified && group.unmatched.get(verified.hash)
|
|
1134
|
+
const identity = verified && this.automaticIdentityKey(verified)
|
|
1116
1135
|
if (!wanted || wanted.identity === identity) {
|
|
1117
1136
|
continue
|
|
1118
1137
|
}
|
|
1119
1138
|
for (const candidate of wanted.candidates) {
|
|
1120
1139
|
group.matched.add(pathKey(candidate.path))
|
|
1121
1140
|
}
|
|
1122
|
-
group.unmatched.delete(hash)
|
|
1123
|
-
const remaining = group.unmatchedIdentities.get(wanted.identity) - 1
|
|
1124
|
-
if (remaining) {
|
|
1125
|
-
group.unmatchedIdentities.set(wanted.identity, remaining)
|
|
1126
|
-
} else {
|
|
1127
|
-
group.unmatchedIdentities.delete(wanted.identity)
|
|
1128
|
-
}
|
|
1141
|
+
group.unmatched.delete(verified.hash)
|
|
1129
1142
|
}
|
|
1130
1143
|
if (verifiedHashes.size >= ENTRY_BATCH_SIZE) {
|
|
1131
1144
|
await this.vault.registry.rememberHashCache(
|
|
@@ -1164,7 +1177,19 @@ class AutomaticScans {
|
|
|
1164
1177
|
item.state === "checking")
|
|
1165
1178
|
if (!entry) return
|
|
1166
1179
|
await this.refreshGlobalScanReady()
|
|
1167
|
-
if (this.disposed
|
|
1180
|
+
if (this.disposed) return
|
|
1181
|
+
if (!this.globalScanReady) {
|
|
1182
|
+
if (this.entries.get(entry.app) === entry) {
|
|
1183
|
+
this.restorePrevious(entry.app)
|
|
1184
|
+
this.discardChangedPaths(entry.app)
|
|
1185
|
+
this.log("check-skipped", {
|
|
1186
|
+
app: entry.app,
|
|
1187
|
+
reason: "global-scan-required"
|
|
1188
|
+
})
|
|
1189
|
+
}
|
|
1190
|
+
this.schedule()
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1168
1193
|
if (this.manualDepth > 0 || this.currentBusyPromise() ||
|
|
1169
1194
|
this.vault.fileActionProgress) {
|
|
1170
1195
|
this.waitForBusyWork()
|
package/package.json
CHANGED
|
@@ -167,7 +167,8 @@
|
|
|
167
167
|
tab.classList.remove("app-vault-result-attention")
|
|
168
168
|
}
|
|
169
169
|
})
|
|
170
|
-
tab.addEventListener("click", () => {
|
|
170
|
+
tab.addEventListener("click", (event) => {
|
|
171
|
+
if (!event.isTrusted) return
|
|
171
172
|
const signature = status.dataset.resultSignature || ""
|
|
172
173
|
if (!signature) return
|
|
173
174
|
requestAutomaticScanFocus(signature)
|
|
@@ -221,7 +221,7 @@ test("the app workspace presents the automatic-result coachmark without starting
|
|
|
221
221
|
assert.doesNotMatch(present, /innerHTML|post\(|btn-scan\.click/)
|
|
222
222
|
})
|
|
223
223
|
|
|
224
|
-
test("selecting a badged row acknowledges it without delaying navigation", async () => {
|
|
224
|
+
test("user-selecting a badged row acknowledges it without delaying navigation", async () => {
|
|
225
225
|
const script = await fs.promises.readFile(
|
|
226
226
|
path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
|
|
227
227
|
const parent = new JSDOM("", { url: "http://localhost/" })
|
|
@@ -256,14 +256,19 @@ test("selecting a badged row acknowledges it without delaying navigation", async
|
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
dom.window.eval(script)
|
|
260
259
|
const tab = dom.window.document.getElementById("save-space-tab")
|
|
261
|
-
let
|
|
262
|
-
tab.addEventListener(
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
let clickHandler
|
|
261
|
+
const addEventListener = tab.addEventListener.bind(tab)
|
|
262
|
+
tab.addEventListener = (type, listener, options) => {
|
|
263
|
+
if (type === "click") clickHandler = listener
|
|
264
|
+
addEventListener(type, listener, options)
|
|
265
|
+
}
|
|
266
|
+
dom.window.eval(script)
|
|
267
|
+
let preventedByHandler = false
|
|
268
|
+
clickHandler({
|
|
269
|
+
isTrusted: true,
|
|
270
|
+
preventDefault: () => { preventedByHandler = true }
|
|
265
271
|
})
|
|
266
|
-
tab.click()
|
|
267
272
|
|
|
268
273
|
await waitFor(() => requests.length === 1)
|
|
269
274
|
assert.equal(preventedByHandler, false)
|
|
@@ -289,6 +294,49 @@ test("selecting a badged row acknowledges it without delaying navigation", async
|
|
|
289
294
|
parent.window.close()
|
|
290
295
|
})
|
|
291
296
|
|
|
297
|
+
test("programmatic tab clicks do not acknowledge a result", async () => {
|
|
298
|
+
const script = await fs.promises.readFile(
|
|
299
|
+
path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
|
|
300
|
+
const signature = "e".repeat(64)
|
|
301
|
+
const parent = new JSDOM("", { url: "http://localhost/" })
|
|
302
|
+
const dom = new JSDOM(`<a id="save-space-tab" href="/vault/app/ComfyUI">
|
|
303
|
+
<span data-app-vault-result-badge></span>
|
|
304
|
+
<span data-app-vault-mode data-app="ComfyUI" data-mode="automatic"
|
|
305
|
+
data-ready="true" data-result-signature="${signature}">
|
|
306
|
+
<span data-app-vault-mode-label>Auto</span>
|
|
307
|
+
</span>
|
|
308
|
+
</a>`, {
|
|
309
|
+
runScripts: "outside-only",
|
|
310
|
+
url: "http://localhost/v/ComfyUI"
|
|
311
|
+
})
|
|
312
|
+
Object.defineProperty(dom.window, "parent", {
|
|
313
|
+
configurable: true,
|
|
314
|
+
value: parent.window
|
|
315
|
+
})
|
|
316
|
+
parent.window.postMessage = () => {}
|
|
317
|
+
const requests = []
|
|
318
|
+
dom.window.fetch = async (url, options = {}) => {
|
|
319
|
+
requests.push({ url, options })
|
|
320
|
+
return { ok: true, json: async () => ({}) }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
dom.window.eval(script)
|
|
324
|
+
const tab = dom.window.document.getElementById("save-space-tab")
|
|
325
|
+
tab.addEventListener("click", (event) => event.preventDefault())
|
|
326
|
+
tab.click()
|
|
327
|
+
|
|
328
|
+
assert.equal(requests.length, 0)
|
|
329
|
+
assert.equal(tab.querySelector("[data-app-vault-result-badge]").hidden,
|
|
330
|
+
false)
|
|
331
|
+
assert.equal(dom.window.document.querySelector("[data-app-vault-mode]")
|
|
332
|
+
.dataset.resultSignature, signature)
|
|
333
|
+
assert.equal(dom.window.sessionStorage.getItem(
|
|
334
|
+
"pinokio:vault:auto-scan-focus:ComfyUI"), null)
|
|
335
|
+
|
|
336
|
+
dom.window.close()
|
|
337
|
+
parent.window.close()
|
|
338
|
+
})
|
|
339
|
+
|
|
292
340
|
test("a retained Disk Saver handoff targets the visible existing frame", async () => {
|
|
293
341
|
const template = await fs.promises.readFile(
|
|
294
342
|
path.join(root, "server", "views", "app.ejs"), "utf8")
|
|
@@ -336,8 +384,15 @@ test("a retained Disk Saver handoff targets the visible existing frame", async (
|
|
|
336
384
|
json: async () => ({ acknowledged: true, app: "ComfyUI" })
|
|
337
385
|
})
|
|
338
386
|
|
|
387
|
+
const tab = dom.window.document.getElementById("save-space-tab")
|
|
388
|
+
let clickHandler
|
|
389
|
+
const addEventListener = tab.addEventListener.bind(tab)
|
|
390
|
+
tab.addEventListener = (type, listener, options) => {
|
|
391
|
+
if (type === "click") clickHandler = listener
|
|
392
|
+
addEventListener(type, listener, options)
|
|
393
|
+
}
|
|
339
394
|
dom.window.eval(script)
|
|
340
|
-
|
|
395
|
+
clickHandler({ isTrusted: true })
|
|
341
396
|
await waitFor(() => visibleMessages.length === 1)
|
|
342
397
|
|
|
343
398
|
assert.deepEqual(hiddenMessages, [])
|
|
@@ -265,6 +265,46 @@ describe("automatic app checks", () => {
|
|
|
265
265
|
await close(vault)
|
|
266
266
|
})
|
|
267
267
|
|
|
268
|
+
test("a watcher batch retains a path recreated after deletion", async () => {
|
|
269
|
+
const home = await makeHome()
|
|
270
|
+
const app = "delete-create-app"
|
|
271
|
+
const root = path.join(home, "api", app)
|
|
272
|
+
const temporary = path.join(root, "temporary")
|
|
273
|
+
const recreated = path.join(temporary, "model.bin")
|
|
274
|
+
const automatic = makeAutomatic({
|
|
275
|
+
enabled: true,
|
|
276
|
+
kernel: { homedir: home, platform: "darwin" }
|
|
277
|
+
})
|
|
278
|
+
automatic.observedApps.add(app)
|
|
279
|
+
|
|
280
|
+
automatic.handleWatcherEvents(null, [
|
|
281
|
+
{ type: "delete", path: temporary },
|
|
282
|
+
{ type: "create", path: recreated }
|
|
283
|
+
])
|
|
284
|
+
|
|
285
|
+
assert.deepEqual([...automatic.changedPaths.get(app)], [recreated])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test("a watcher batch removes a path deleted after creation", async () => {
|
|
289
|
+
const home = await makeHome()
|
|
290
|
+
const app = "create-delete-app"
|
|
291
|
+
const root = path.join(home, "api", app)
|
|
292
|
+
const temporary = path.join(root, "temporary")
|
|
293
|
+
const removed = path.join(temporary, "model.bin")
|
|
294
|
+
const automatic = makeAutomatic({
|
|
295
|
+
enabled: true,
|
|
296
|
+
kernel: { homedir: home, platform: "darwin" }
|
|
297
|
+
})
|
|
298
|
+
automatic.observedApps.add(app)
|
|
299
|
+
|
|
300
|
+
automatic.handleWatcherEvents(null, [
|
|
301
|
+
{ type: "create", path: removed },
|
|
302
|
+
{ type: "delete", path: temporary }
|
|
303
|
+
])
|
|
304
|
+
|
|
305
|
+
assert.equal(automatic.changedPaths.has(app), false)
|
|
306
|
+
})
|
|
307
|
+
|
|
268
308
|
test("disposing automatic checks releases the watcher and pending work", async () => {
|
|
269
309
|
const home = await makeHome()
|
|
270
310
|
const appRoot = path.join(home, "api", "disposed-app")
|
|
@@ -405,6 +445,71 @@ describe("automatic app checks", () => {
|
|
|
405
445
|
assert.equal(checksStarted, 0)
|
|
406
446
|
})
|
|
407
447
|
|
|
448
|
+
test("scheduler coalesces wakeups while dispatch is in progress", async () => {
|
|
449
|
+
let releaseReadiness
|
|
450
|
+
let readinessCalls = 0
|
|
451
|
+
let dispatches = 0
|
|
452
|
+
const automatic = makeAutomatic({
|
|
453
|
+
enabled: true,
|
|
454
|
+
registry: {},
|
|
455
|
+
runExclusive: () => {
|
|
456
|
+
dispatches += 1
|
|
457
|
+
return new Promise(() => {})
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
automatic.entries.set("demo", {
|
|
461
|
+
app: "demo",
|
|
462
|
+
state: "checking",
|
|
463
|
+
paths: []
|
|
464
|
+
})
|
|
465
|
+
automatic.globalScanReady = true
|
|
466
|
+
automatic.refreshGlobalScanReady = async () => {
|
|
467
|
+
readinessCalls += 1
|
|
468
|
+
if (readinessCalls === 1) {
|
|
469
|
+
await new Promise((resolve) => { releaseReadiness = resolve })
|
|
470
|
+
}
|
|
471
|
+
automatic.globalScanReady = true
|
|
472
|
+
}
|
|
473
|
+
automatic.appRootIsAvailable = async () => true
|
|
474
|
+
automatic.appIsRunning = () => false
|
|
475
|
+
|
|
476
|
+
automatic.schedule()
|
|
477
|
+
await waitFor(() => typeof releaseReadiness === "function",
|
|
478
|
+
"scheduler readiness pause")
|
|
479
|
+
automatic.schedule()
|
|
480
|
+
releaseReadiness()
|
|
481
|
+
await waitFor(() => dispatches > 0, "automatic dispatch")
|
|
482
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
483
|
+
|
|
484
|
+
assert.equal(dispatches, 1)
|
|
485
|
+
assert.equal(readinessCalls, 1)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test("queued checks are cleared if global readiness disappears", async () => {
|
|
489
|
+
const automatic = makeAutomatic({
|
|
490
|
+
enabled: true,
|
|
491
|
+
registry: {}
|
|
492
|
+
})
|
|
493
|
+
automatic.entries.set("demo", {
|
|
494
|
+
app: "demo",
|
|
495
|
+
state: "checking",
|
|
496
|
+
paths: ["/pinokio/api/demo/model.bin"]
|
|
497
|
+
})
|
|
498
|
+
automatic.changedPaths.set("demo", new Set([
|
|
499
|
+
"/pinokio/api/demo/model.bin"
|
|
500
|
+
]))
|
|
501
|
+
automatic.refreshGlobalScanReady = async () => {
|
|
502
|
+
automatic.globalScanReady = false
|
|
503
|
+
}
|
|
504
|
+
automatic.log = () => {}
|
|
505
|
+
|
|
506
|
+
automatic.schedule()
|
|
507
|
+
await waitFor(() => !automatic.drainQueued, "automatic queue cleanup")
|
|
508
|
+
|
|
509
|
+
assert.equal(automatic.entries.has("demo"), false)
|
|
510
|
+
assert.equal(automatic.changedPaths.has("demo"), false)
|
|
511
|
+
})
|
|
512
|
+
|
|
408
513
|
test("Linux starts no watcher and ignores automatic lifecycle work", async () => {
|
|
409
514
|
const home = await makeHome()
|
|
410
515
|
const appRoot = path.join(home, "api", "linux-app")
|
|
@@ -1363,6 +1468,80 @@ describe("automatic app checks", () => {
|
|
|
1363
1468
|
await close(vault)
|
|
1364
1469
|
})
|
|
1365
1470
|
|
|
1471
|
+
test("files that become hardlinks after staging are not duplicates", async () => {
|
|
1472
|
+
const home = await makeHome()
|
|
1473
|
+
const app = "became-hardlinks-app"
|
|
1474
|
+
const appRoot = path.join(home, "api", app)
|
|
1475
|
+
const first = path.join(appRoot, "first.bin")
|
|
1476
|
+
const second = path.join(appRoot, "second.bin")
|
|
1477
|
+
await fs.promises.mkdir(appRoot)
|
|
1478
|
+
await fs.promises.writeFile(first, "same-content")
|
|
1479
|
+
await fs.promises.writeFile(second, "same-content")
|
|
1480
|
+
const before = await Promise.all([
|
|
1481
|
+
fs.promises.lstat(first),
|
|
1482
|
+
fs.promises.lstat(second)
|
|
1483
|
+
])
|
|
1484
|
+
assert.notEqual(before[0].ino, before[1].ino)
|
|
1485
|
+
const vault = await makeVault(home)
|
|
1486
|
+
vault.automaticScans.candidateThreshold = async () => 1
|
|
1487
|
+
const stage = vault.registry.stageAutomaticPrecheckFiles.bind(
|
|
1488
|
+
vault.registry)
|
|
1489
|
+
vault.registry.stageAutomaticPrecheckFiles = async (...args) => {
|
|
1490
|
+
await stage(...args)
|
|
1491
|
+
await fs.promises.unlink(second)
|
|
1492
|
+
await fs.promises.link(first, second)
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
vault.automaticScans.queueApp(app, [first, second])
|
|
1496
|
+
await waitFor(() => !vault.automaticScans.active &&
|
|
1497
|
+
!vault.automaticScans.entries.has(app))
|
|
1498
|
+
|
|
1499
|
+
const after = await Promise.all([
|
|
1500
|
+
fs.promises.lstat(first),
|
|
1501
|
+
fs.promises.lstat(second)
|
|
1502
|
+
])
|
|
1503
|
+
assert.equal(after[0].ino, after[1].ino)
|
|
1504
|
+
assert.deepEqual(vault.automaticScans.snapshot().rows, [])
|
|
1505
|
+
await close(vault)
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
test("hardlinks that become independent after staging are duplicates", async () => {
|
|
1509
|
+
const home = await makeHome()
|
|
1510
|
+
const app = "split-hardlinks-app"
|
|
1511
|
+
const appRoot = path.join(home, "api", app)
|
|
1512
|
+
const first = path.join(appRoot, "first.bin")
|
|
1513
|
+
const second = path.join(appRoot, "second.bin")
|
|
1514
|
+
await fs.promises.mkdir(appRoot)
|
|
1515
|
+
await fs.promises.writeFile(first, "same-content")
|
|
1516
|
+
await fs.promises.link(first, second)
|
|
1517
|
+
const before = await Promise.all([
|
|
1518
|
+
fs.promises.lstat(first),
|
|
1519
|
+
fs.promises.lstat(second)
|
|
1520
|
+
])
|
|
1521
|
+
assert.equal(before[0].ino, before[1].ino)
|
|
1522
|
+
const vault = await makeVault(home)
|
|
1523
|
+
vault.automaticScans.candidateThreshold = async () => 1
|
|
1524
|
+
const stage = vault.registry.stageAutomaticPrecheckFiles.bind(
|
|
1525
|
+
vault.registry)
|
|
1526
|
+
vault.registry.stageAutomaticPrecheckFiles = async (...args) => {
|
|
1527
|
+
await stage(...args)
|
|
1528
|
+
await fs.promises.unlink(second)
|
|
1529
|
+
await fs.promises.writeFile(second, "same-content")
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
vault.automaticScans.queueApp(app, [first, second])
|
|
1533
|
+
await waitFor(() => !vault.automaticScans.active &&
|
|
1534
|
+
vault.automaticScans.entries.get(app)?.state === "result")
|
|
1535
|
+
|
|
1536
|
+
const after = await Promise.all([
|
|
1537
|
+
fs.promises.lstat(first),
|
|
1538
|
+
fs.promises.lstat(second)
|
|
1539
|
+
])
|
|
1540
|
+
assert.notEqual(after[0].ino, after[1].ino)
|
|
1541
|
+
assert.equal(vault.automaticScans.snapshot().rows[0].state, "result")
|
|
1542
|
+
await close(vault)
|
|
1543
|
+
})
|
|
1544
|
+
|
|
1366
1545
|
test("stale file rows and known identical inodes are not peers", async () => {
|
|
1367
1546
|
const home = await makeHome()
|
|
1368
1547
|
const appRoot = path.join(home, "api", "no-peer-app")
|