pinokiod 8.0.7 → 8.0.9

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.
@@ -0,0 +1,316 @@
1
+ const assert = require("node:assert/strict")
2
+ const fs = require("node:fs/promises")
3
+ const path = require("node:path")
4
+ const test = require("node:test")
5
+ const { JSDOM, VirtualConsole } = require("jsdom")
6
+
7
+ const repoRoot = path.resolve(__dirname, "..")
8
+ const commonScriptPath = path.join(repoRoot, "server/public/common.js")
9
+
10
+ async function waitFor(predicate, message = "condition") {
11
+ const deadline = Date.now() + 1000
12
+ while (Date.now() < deadline) {
13
+ if (predicate()) return
14
+ await new Promise((resolve) => setTimeout(resolve, 5))
15
+ }
16
+ assert.fail(`Timed out waiting for ${message}`)
17
+ }
18
+
19
+ function response(payload, { ok = false, status = 423 } = {}) {
20
+ return {
21
+ ok,
22
+ status,
23
+ json: async () => payload,
24
+ }
25
+ }
26
+
27
+ async function createHarness() {
28
+ const source = await fs.readFile(commonScriptPath, "utf8")
29
+
30
+ const state = {
31
+ buttonsDisabled: false,
32
+ clearedIntervals: new Set(),
33
+ closeCount: 0,
34
+ fireOptions: null,
35
+ htmlContainer: null,
36
+ intervals: new Map(),
37
+ nextIntervalId: 1,
38
+ resolveFire: null,
39
+ validationMessage: "",
40
+ visible: false,
41
+ }
42
+ const virtualConsole = new VirtualConsole()
43
+ virtualConsole.on("jsdomError", (error) => {
44
+ throw error
45
+ })
46
+
47
+ const dom = new JSDOM("<!doctype html><body></body>", {
48
+ runScripts: "dangerously",
49
+ url: "http://localhost/",
50
+ virtualConsole,
51
+ beforeParse(window) {
52
+ window.fetch = async () => response({}, { ok: true, status: 200 })
53
+ window.matchMedia = () => ({
54
+ matches: false,
55
+ addEventListener() {},
56
+ removeEventListener() {},
57
+ })
58
+ window.ResizeObserver = class {
59
+ observe() {}
60
+ disconnect() {}
61
+ }
62
+ window.setInterval = (callback, delay) => {
63
+ const id = state.nextIntervalId++
64
+ state.intervals.set(id, { callback, delay })
65
+ return id
66
+ }
67
+ window.clearInterval = (id) => {
68
+ state.clearedIntervals.add(id)
69
+ state.intervals.delete(id)
70
+ }
71
+ window.Swal = {
72
+ disableButtons() {
73
+ state.buttonsDisabled = true
74
+ },
75
+ enableButtons() {
76
+ state.buttonsDisabled = false
77
+ },
78
+ close() {
79
+ if (!state.visible) return
80
+ state.closeCount += 1
81
+ state.visible = false
82
+ if (state.fireOptions && typeof state.fireOptions.willClose === "function") {
83
+ state.fireOptions.willClose()
84
+ }
85
+ if (state.resolveFire) state.resolveFire({ isConfirmed: false })
86
+ },
87
+ fire(options) {
88
+ state.fireOptions = options
89
+ state.htmlContainer = window.document.createElement("div")
90
+ state.htmlContainer.innerHTML = options.html || ""
91
+ window.document.body.appendChild(state.htmlContainer)
92
+ state.visible = true
93
+ if (typeof options.didOpen === "function") options.didOpen()
94
+ return new Promise((resolve) => {
95
+ state.resolveFire = resolve
96
+ })
97
+ },
98
+ getHtmlContainer() {
99
+ return state.htmlContainer
100
+ },
101
+ isVisible() {
102
+ return state.visible
103
+ },
104
+ showValidationMessage(message) {
105
+ state.validationMessage = message
106
+ },
107
+ }
108
+ },
109
+ })
110
+
111
+ const script = dom.window.document.createElement("script")
112
+ script.textContent = source
113
+ dom.window.document.body.appendChild(script)
114
+
115
+ state.dismiss = () => {
116
+ if (!state.visible || state.buttonsDisabled) return false
117
+ state.visible = false
118
+ if (state.fireOptions && typeof state.fireOptions.willClose === "function") {
119
+ state.fireOptions.willClose()
120
+ }
121
+ state.resolveFire({ isConfirmed: false })
122
+ return true
123
+ }
124
+ state.getRetryIntervalId = () => Array.from(state.intervals.entries())
125
+ .filter(([, interval]) => interval.delay === 2000)
126
+ .map(([id]) => id)
127
+ .pop()
128
+ state.runInterval = async () => {
129
+ const interval = state.intervals.get(state.getRetryIntervalId())
130
+ assert.ok(interval, "expected an active retry interval")
131
+ return await interval.callback()
132
+ }
133
+
134
+ return { dom, state, api: dom.window.PinokioPathRemoval }
135
+ }
136
+
137
+ test("path-removal modal renders identified blockers and escapes backend text", async (t) => {
138
+ const { api, dom, state } = await createHarness()
139
+ t.after(() => dom.window.close())
140
+
141
+ const shown = api.show({
142
+ details: {
143
+ operation: "Delete <app>",
144
+ blockers: [{
145
+ name: "G <HUB>",
146
+ executable: "C:\\Program Files\\G & HUB\\lghub.exe",
147
+ pid: 4321,
148
+ }],
149
+ remainingCount: 25,
150
+ remaining: Array.from({ length: 20 }, (_, index) => `C:\\runtime\\file-${index}.dll`),
151
+ },
152
+ retry: async () => ({ ok: true }),
153
+ })
154
+
155
+ assert.equal(state.fireOptions.title, "Delete &lt;app&gt;")
156
+ assert.match(state.htmlContainer.innerHTML, /G &lt;HUB&gt;/)
157
+ assert.doesNotMatch(state.htmlContainer.innerHTML, /G <HUB>/)
158
+ assert.match(state.htmlContainer.textContent, /C:\\Program Files\\G & HUB\\lghub\.exe · PID 4321/)
159
+ assert.match(state.htmlContainer.textContent, /Show first 20 of 25 remaining items/)
160
+ assert.match(state.htmlContainer.textContent, /Close the app above/)
161
+ assert.equal(state.htmlContainer.querySelector(".pinokio-path-blocker-icon"), null)
162
+
163
+ const intervalId = state.getRetryIntervalId()
164
+ state.dismiss()
165
+ const result = await shown
166
+ assert.equal(result.cancelled, true)
167
+ assert.equal(state.intervals.has(intervalId), false)
168
+ })
169
+
170
+ test("path-removal modal reports unattributed EACCES without naming an application", async (t) => {
171
+ const { api, dom, state } = await createHarness()
172
+ t.after(() => dom.window.close())
173
+
174
+ const shown = api.show({
175
+ details: {
176
+ operation: "Delete app",
177
+ causeCode: "EACCES",
178
+ blockers: [],
179
+ remainingCount: 1,
180
+ remaining: ["C:\\runtime\\protected.dll"],
181
+ },
182
+ retry: async () => ({ ok: true }),
183
+ })
184
+
185
+ assert.match(state.htmlContainer.textContent, /Windows couldn't remove one remaining item \(EACCES\)/)
186
+ assert.match(state.htmlContainer.textContent, /Check permissions or security software/)
187
+ assert.match(state.htmlContainer.textContent, /Resolve the access issue/)
188
+ assert.doesNotMatch(state.htmlContainer.textContent, /Close the app above/)
189
+
190
+ state.dismiss()
191
+ assert.equal((await shown).cancelled, true)
192
+ })
193
+
194
+ test("path-removal request retries automatically without overlapping and resolves on success", async (t) => {
195
+ const { api, dom, state } = await createHarness()
196
+ t.after(() => dom.window.close())
197
+
198
+ let requestCount = 0
199
+ let resolveRetry
200
+ const retryResponse = new Promise((resolve) => {
201
+ resolveRetry = resolve
202
+ })
203
+ const resultPromise = api.request(async () => {
204
+ requestCount += 1
205
+ if (requestCount === 1) {
206
+ return response({
207
+ success: false,
208
+ error: {
209
+ code: "PINOKIO_PATH_REMOVE_BLOCKED",
210
+ operation: "Replace Miniforge",
211
+ blockers: [{ name: "First blocker", pid: 10 }],
212
+ remainingCount: 1,
213
+ remaining: ["C:\\runtime\\first.dll"],
214
+ },
215
+ })
216
+ }
217
+ if (requestCount === 2) return await retryResponse
218
+ return response({ ok: true }, { ok: true, status: 200 })
219
+ })
220
+
221
+ await waitFor(() => state.fireOptions, "path-removal modal")
222
+ const intervalId = state.getRetryIntervalId()
223
+ assert.equal(state.intervals.get(intervalId).delay, 2000)
224
+
225
+ const firstAttempt = state.runInterval()
226
+ assert.equal(state.buttonsDisabled, true)
227
+ assert.equal(state.dismiss(), false, "Stop must be unavailable during an active removal")
228
+ assert.equal(state.visible, true)
229
+ await state.runInterval()
230
+ assert.equal(requestCount, 2, "a pending retry must suppress overlapping retries")
231
+
232
+ resolveRetry(response({
233
+ success: false,
234
+ error: {
235
+ code: "PINOKIO_PATH_REMOVE_BLOCKED",
236
+ operation: "Replace Miniforge",
237
+ blockers: [{ name: "Second blocker", pid: 20 }],
238
+ remainingCount: 1,
239
+ remaining: ["C:\\runtime\\second.dll"],
240
+ },
241
+ }))
242
+ await firstAttempt
243
+ assert.equal(state.buttonsDisabled, false)
244
+ assert.match(state.htmlContainer.textContent, /Second blocker/)
245
+ assert.doesNotMatch(state.htmlContainer.textContent, /First blocker/)
246
+
247
+ await state.runInterval()
248
+ const result = await resultPromise
249
+ assert.equal(result.ok, true)
250
+ assert.equal(requestCount, 3)
251
+ assert.equal(state.closeCount, 1)
252
+ assert.equal(state.intervals.has(intervalId), false)
253
+ assert.ok(state.clearedIntervals.has(intervalId))
254
+ })
255
+
256
+ test("manual retry keeps the modal open when blocked and Stop cancels future retries", async (t) => {
257
+ const { api, dom, state } = await createHarness()
258
+ t.after(() => dom.window.close())
259
+
260
+ let retryCount = 0
261
+ const shown = api.show({
262
+ details: {
263
+ operation: "Delete app",
264
+ blockers: [{ name: "Lock holder", pid: 99 }],
265
+ remainingCount: 1,
266
+ remaining: ["C:\\app\\locked.dll"],
267
+ },
268
+ retry: async () => {
269
+ retryCount += 1
270
+ return {
271
+ code: "PINOKIO_PATH_REMOVE_BLOCKED",
272
+ operation: "Delete app",
273
+ blockers: [{ name: "Lock holder", pid: 99 }],
274
+ remainingCount: 1,
275
+ remaining: ["C:\\app\\locked.dll"],
276
+ }
277
+ },
278
+ })
279
+
280
+ assert.equal(await state.fireOptions.preConfirm(), false)
281
+ assert.equal(retryCount, 1)
282
+ assert.equal(state.visible, true)
283
+ assert.equal(state.validationMessage, "Windows still cannot remove the remaining items.")
284
+ assert.equal(state.buttonsDisabled, false)
285
+
286
+ const intervalId = state.getRetryIntervalId()
287
+ state.dismiss()
288
+ const result = await shown
289
+ assert.equal(result.cancelled, true)
290
+ assert.equal(state.intervals.has(intervalId), false)
291
+ assert.ok(state.clearedIntervals.has(intervalId))
292
+ })
293
+
294
+ test("manual retry escapes unexpected error messages", async (t) => {
295
+ const { api, dom, state } = await createHarness()
296
+ t.after(() => dom.window.close())
297
+
298
+ const shown = api.show({
299
+ details: {
300
+ operation: "Delete app",
301
+ blockers: [],
302
+ remainingCount: 1,
303
+ remaining: ["C:\\app\\locked.dll"],
304
+ },
305
+ retry: async () => {
306
+ throw new Error('<img src=x onerror="window.compromised=true">')
307
+ },
308
+ })
309
+
310
+ assert.equal(await state.fireOptions.preConfirm(), false)
311
+ assert.equal(state.validationMessage, "&lt;img src=x onerror=&quot;window.compromised=true&quot;&gt;")
312
+ assert.equal(state.buttonsDisabled, false)
313
+
314
+ state.dismiss()
315
+ assert.equal((await shown).cancelled, true)
316
+ })
@@ -0,0 +1,189 @@
1
+ const test = require("node:test")
2
+ const assert = require("node:assert/strict")
3
+ const fs = require("fs")
4
+ const os = require("os")
5
+ const path = require("path")
6
+
7
+ const {
8
+ PATH_REMOVAL_BLOCKED_CODE,
9
+ collectRemaining,
10
+ createPathRemover,
11
+ } = require("../kernel/path_removal")
12
+
13
+ test("removePath preserves ordinary fs.rm behavior", async () => {
14
+ const calls = []
15
+ const removePath = createPathRemover({
16
+ platform: "darwin",
17
+ fsPromises: {
18
+ rm: async (target, options) => calls.push({ target, options }),
19
+ },
20
+ })
21
+
22
+ const target = path.resolve(os.tmpdir(), "pinokio-remove-success")
23
+ const result = await removePath(target, {
24
+ recursive: true,
25
+ force: true,
26
+ operation: "Deleting test data",
27
+ })
28
+
29
+ assert.deepEqual(result, { removed: true, target })
30
+ assert.deepEqual(calls, [{ target, options: { recursive: true, force: true } }])
31
+ })
32
+
33
+ test("removePath rethrows lock-shaped errors outside Windows", async () => {
34
+ const original = Object.assign(new Error("busy"), { code: "EPERM" })
35
+ const removePath = createPathRemover({
36
+ platform: "darwin",
37
+ fsPromises: {
38
+ rm: async () => { throw original },
39
+ },
40
+ })
41
+
42
+ await assert.rejects(
43
+ removePath("/tmp/pinokio-remove-busy", { recursive: true }),
44
+ (error) => error === original,
45
+ )
46
+ })
47
+
48
+ test("removePath reports Windows blockers after partial recursive deletion and succeeds on retry", async () => {
49
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-blocked-"))
50
+ const lockedDir = path.join(root, "pkgs", "git", "Library", "bin")
51
+ const lockedFile = path.join(lockedDir, "zlib1.dll")
52
+ const removableFile = path.join(root, "already-removed.txt")
53
+ await fs.promises.mkdir(lockedDir, { recursive: true })
54
+ await fs.promises.writeFile(lockedFile, "locked")
55
+ await fs.promises.writeFile(removableFile, "remove me")
56
+
57
+ let locked = true
58
+ const inspected = []
59
+ const fsPromises = {
60
+ lstat: fs.promises.lstat.bind(fs.promises),
61
+ readdir: fs.promises.readdir.bind(fs.promises),
62
+ rm: async (target, options) => {
63
+ if (!locked) {
64
+ return fs.promises.rm(target, options)
65
+ }
66
+ await fs.promises.rm(removableFile, { force: true })
67
+ throw Object.assign(new Error("operation not permitted"), { code: "EPERM" })
68
+ },
69
+ }
70
+ const removePath = createPathRemover({
71
+ platform: "win32",
72
+ fsPromises,
73
+ inspectLocks: async (files) => {
74
+ inspected.push(files)
75
+ return {
76
+ blockers: [{ pid: 8544, name: "Logitech G HUB" }],
77
+ }
78
+ },
79
+ })
80
+
81
+ await assert.rejects(
82
+ removePath(root, {
83
+ recursive: true,
84
+ force: true,
85
+ operation: "Updating Miniforge",
86
+ }),
87
+ (error) => {
88
+ assert.equal(error.code, PATH_REMOVAL_BLOCKED_CODE)
89
+ assert.equal(error.operation, "Updating Miniforge")
90
+ assert.equal(error.causeCode, "EPERM")
91
+ assert.deepEqual(error.remaining, [lockedFile])
92
+ assert.equal(error.remainingCount, 1)
93
+ assert.equal(error.blockers[0].pid, 8544)
94
+ assert.equal(error.toJSON().code, PATH_REMOVAL_BLOCKED_CODE)
95
+ return true
96
+ },
97
+ )
98
+
99
+ assert.equal(await fs.promises.access(removableFile).then(() => true).catch(() => false), false)
100
+ assert.deepEqual(inspected, [[lockedFile]])
101
+
102
+ locked = false
103
+ const result = await removePath(root, { recursive: true, force: true })
104
+ assert.equal(result.removed, true)
105
+ assert.equal(await fs.promises.access(root).then(() => true).catch(() => false), false)
106
+ })
107
+
108
+ test("removePath reports and logs an unattributed Windows removal failure without claiming a file lock", async (t) => {
109
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-denied-"))
110
+ t.after(() => fs.promises.rm(root, { recursive: true, force: true }))
111
+ const remainingFile = path.join(root, "denied.txt")
112
+ await fs.promises.writeFile(remainingFile, "denied")
113
+
114
+ const removePath = createPathRemover({
115
+ platform: "win32",
116
+ fsPromises: {
117
+ lstat: fs.promises.lstat.bind(fs.promises),
118
+ readdir: fs.promises.readdir.bind(fs.promises),
119
+ rm: async () => { throw Object.assign(new Error("access denied"), { code: "EACCES" }) },
120
+ },
121
+ inspectLocks: async () => { throw new Error("diagnostic failed") },
122
+ })
123
+
124
+ const originalWarn = console.warn
125
+ let warning
126
+ console.warn = (...args) => { warning = args }
127
+ try {
128
+ await assert.rejects(
129
+ removePath(root, { recursive: true, force: true }),
130
+ (error) => {
131
+ assert.equal(error.code, PATH_REMOVAL_BLOCKED_CODE)
132
+ assert.equal(error.causeCode, "EACCES")
133
+ assert.deepEqual(error.blockers, [])
134
+ assert.doesNotMatch(error.message, /still in use/)
135
+ return true
136
+ },
137
+ )
138
+ } finally {
139
+ console.warn = originalWarn
140
+ }
141
+ assert.equal(warning[0], "[path removal] Restart Manager inspection failed")
142
+ assert.match(warning[1].message, /diagnostic failed/)
143
+ })
144
+
145
+ test("removePath inspects every remaining file but limits the serialized preview", async (t) => {
146
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-preview-"))
147
+ t.after(() => fs.promises.rm(root, { recursive: true, force: true }))
148
+ const files = Array.from({ length: 25 }, (_, index) => path.join(root, `${index}.txt`))
149
+ await Promise.all(files.map((file) => fs.promises.writeFile(file, "remaining")))
150
+
151
+ let inspected = []
152
+ const removePath = createPathRemover({
153
+ platform: "win32",
154
+ fsPromises: {
155
+ lstat: fs.promises.lstat.bind(fs.promises),
156
+ readdir: fs.promises.readdir.bind(fs.promises),
157
+ rm: async () => { throw Object.assign(new Error("busy"), { code: "EBUSY" }) },
158
+ },
159
+ inspectLocks: async (remaining) => {
160
+ inspected = remaining
161
+ return { blockers: [] }
162
+ },
163
+ })
164
+
165
+ await assert.rejects(removePath(root, { recursive: true, force: true }), (error) => {
166
+ assert.equal(error.remainingCount, 25)
167
+ assert.equal(error.remaining.length, 20)
168
+ assert.equal(error.toJSON().remaining.length, 20)
169
+ return true
170
+ })
171
+ assert.equal(inspected.length, 25)
172
+ })
173
+
174
+ test("remaining-file discovery does not traverse symbolic links", async (t) => {
175
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-links-"))
176
+ const outside = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-outside-"))
177
+ const outsideFile = path.join(outside, "outside.txt")
178
+ const link = path.join(root, "linked")
179
+ await fs.promises.writeFile(outsideFile, "outside")
180
+ await fs.promises.symlink(outside, link, process.platform === "win32" ? "junction" : "dir")
181
+ t.after(async () => {
182
+ await fs.promises.rm(root, { recursive: true, force: true })
183
+ await fs.promises.rm(outside, { recursive: true, force: true })
184
+ })
185
+
186
+ const remaining = await collectRemaining(root)
187
+ assert.equal(remaining.files.includes(link), true)
188
+ assert.equal(remaining.paths.includes(outsideFile), false)
189
+ })
@@ -0,0 +1,78 @@
1
+ const test = require("node:test")
2
+ const assert = require("node:assert/strict")
3
+ const fs = require("fs")
4
+ const os = require("os")
5
+ const path = require("path")
6
+ const { spawn } = require("child_process")
7
+
8
+ const { PATH_REMOVAL_BLOCKED_CODE, removePath } = require("../kernel/path_removal")
9
+
10
+ const waitForExit = (child) => {
11
+ if (child.exitCode !== null || child.signalCode !== null) {
12
+ return Promise.resolve()
13
+ }
14
+ return new Promise((resolve) => child.once("exit", resolve))
15
+ }
16
+
17
+ const waitForReady = (child) => new Promise((resolve, reject) => {
18
+ let output = ""
19
+ const timer = setTimeout(() => reject(new Error("DLL holder did not become ready")), 10000)
20
+ child.stdout.on("data", (chunk) => {
21
+ output += chunk.toString()
22
+ if (output.includes("READY")) {
23
+ clearTimeout(timer)
24
+ resolve()
25
+ }
26
+ })
27
+ child.once("exit", (code) => {
28
+ clearTimeout(timer)
29
+ reject(new Error(`DLL holder exited before ready (${code})`))
30
+ })
31
+ })
32
+
33
+ test("Windows integration: loaded DLL is diagnosed and removable after release", {
34
+ skip: process.platform !== "win32",
35
+ timeout: 30000,
36
+ }, async (t) => {
37
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-windows-lock-"))
38
+ const dllPath = path.join(root, "version.dll")
39
+ const ordinaryFile = path.join(root, "ordinary.txt")
40
+ await fs.promises.copyFile(path.join(process.env.SystemRoot, "System32", "version.dll"), dllPath)
41
+ await fs.promises.writeFile(ordinaryFile, "ordinary")
42
+
43
+ const child = spawn(process.execPath, [path.join(__dirname, "fixtures", "hold-windows-dll.js"), dllPath], {
44
+ stdio: ["pipe", "pipe", "pipe"],
45
+ })
46
+ t.after(async () => {
47
+ if (child.exitCode === null && child.signalCode === null) {
48
+ const exited = waitForExit(child)
49
+ child.kill()
50
+ await exited
51
+ }
52
+ await fs.promises.rm(root, { recursive: true, force: true }).catch(() => {})
53
+ })
54
+ await waitForReady(child)
55
+
56
+ await assert.rejects(
57
+ removePath(root, {
58
+ recursive: true,
59
+ force: true,
60
+ maxRetries: 1,
61
+ retryDelay: 50,
62
+ operation: "Windows integration test",
63
+ }),
64
+ (error) => {
65
+ assert.equal(error.code, PATH_REMOVAL_BLOCKED_CODE)
66
+ assert.equal(error.remaining.includes(dllPath), true)
67
+ assert.equal(error.blockers.some((blocker) => blocker.pid === child.pid), true)
68
+ return true
69
+ },
70
+ )
71
+ assert.equal(await fs.promises.access(ordinaryFile).then(() => true).catch(() => false), false)
72
+
73
+ const exited = new Promise((resolve) => child.once("exit", resolve))
74
+ child.stdin.write("release\n")
75
+ await exited
76
+ await removePath(root, { recursive: true, force: true })
77
+ assert.equal(await fs.promises.access(root).then(() => true).catch(() => false), false)
78
+ })