pinokiod 7.5.21 → 7.5.23

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,269 @@
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
+
9
+ function visibleTextMatches(document, selector, text) {
10
+ return Array.from(document.querySelectorAll(selector)).filter((element) => {
11
+ if (element.textContent.trim() !== text) {
12
+ return false
13
+ }
14
+ return !element.closest(".task-hidden")
15
+ })
16
+ }
17
+
18
+ async function renderSharePanel(shareOverrides, options = {}) {
19
+ const script = await fs.readFile(path.join(repoRoot, "server/public/plugin-detail.js"), "utf8")
20
+ const share = {
21
+ manageable: true,
22
+ githubConnected: false,
23
+ gitInitialized: false,
24
+ hasCommit: false,
25
+ remoteUrl: "",
26
+ remoteWebUrl: "",
27
+ hasPublished: false,
28
+ changeCount: 0,
29
+ aheadCount: 0,
30
+ createUrl: "/github/create",
31
+ commitUrl: "/github/commit",
32
+ pushUrl: "/github/push",
33
+ dir: "/tmp/demo-plugin",
34
+ ...shareOverrides,
35
+ }
36
+ const staticClass = share.remoteWebUrl || (share.remoteUrl && share.githubConnected) ? "" : " task-hidden"
37
+ const remoteClass = share.remoteWebUrl ? "" : " task-hidden"
38
+ const refreshClass = share.remoteUrl && share.githubConnected ? "" : " task-hidden"
39
+ const bootstrap = {
40
+ plugin: { title: "Demo Plugin", cwd: "/tmp/demo-plugin", pluginPath: "plugin/demo/pinokio.js" },
41
+ share,
42
+ apps: [],
43
+ stateUrl: "",
44
+ }
45
+
46
+ const dom = new JSDOM(`<!doctype html>
47
+ <body>
48
+ ${options.actionMarkup || ""}
49
+ <section class="task-side-group">
50
+ <div class="task-section-note" data-plugin-share-note></div>
51
+ <div class="task-share-next-step-main">
52
+ <h3 data-plugin-share-next-title></h3>
53
+ <p data-plugin-share-next-copy></p>
54
+ <div class="task-actions" data-plugin-share-next-actions></div>
55
+ </div>
56
+ <form class="task-share-inline-create task-hidden" data-plugin-share-create-form>
57
+ <input type="text" data-plugin-share-repo-name>
58
+ <select data-plugin-share-visibility><option value="public">Public</option></select>
59
+ <button type="button" data-plugin-share-create-cancel>Cancel</button>
60
+ </form>
61
+ <div class="task-actions plugin-share-static-actions${staticClass}" data-plugin-share-static-actions>
62
+ <a class="task-link-button${remoteClass}" href="${share.remoteWebUrl || "#"}" data-plugin-share-remote-link>
63
+ <span>View on GitHub</span>
64
+ </a>
65
+ <button class="${refreshClass}" type="button" data-plugin-share-refresh><span>Check again</span></button>
66
+ </div>
67
+ <p data-plugin-share-copy></p>
68
+ <div data-plugin-share-feedback hidden></div>
69
+ </section>
70
+ <script id="plugin-detail-bootstrap" type="application/json">${JSON.stringify(bootstrap)}</script>
71
+ </body>`, {
72
+ runScripts: "dangerously",
73
+ url: "http://localhost/plugin",
74
+ virtualConsole: options.virtualConsole,
75
+ })
76
+
77
+ if (typeof options.beforeScript === "function") {
78
+ options.beforeScript(dom.window)
79
+ }
80
+
81
+ const scriptElement = dom.window.document.createElement("script")
82
+ scriptElement.textContent = script
83
+ dom.window.document.body.appendChild(scriptElement)
84
+
85
+ return dom.window.document
86
+ }
87
+
88
+ test("plugin detail hides static GitHub actions until a remote exists", async () => {
89
+ const document = await renderSharePanel()
90
+ const staticActions = document.querySelector("[data-plugin-share-static-actions]")
91
+
92
+ assert.ok(staticActions.classList.contains("task-hidden"))
93
+ assert.equal(visibleTextMatches(document, "a,button", "Check again").length, 1)
94
+ assert.equal(document.querySelector("[data-plugin-share-note]").textContent, "Setup needed")
95
+ })
96
+
97
+ test("plugin detail shows one static refresh action after a remote exists", async () => {
98
+ const document = await renderSharePanel({
99
+ githubConnected: true,
100
+ gitInitialized: true,
101
+ hasCommit: true,
102
+ remoteUrl: "git@github.com:octocat/demo-plugin.git",
103
+ remoteWebUrl: "https://github.com/octocat/demo-plugin",
104
+ hasPublished: true,
105
+ })
106
+ const staticActions = document.querySelector("[data-plugin-share-static-actions]")
107
+
108
+ assert.ok(!staticActions.classList.contains("task-hidden"))
109
+ assert.equal(visibleTextMatches(document, "a,button", "Check again").length, 1)
110
+ assert.equal(visibleTextMatches(document, "a,button", "View on GitHub").length, 1)
111
+ assert.equal(document.querySelector("[data-plugin-share-note]").textContent, "Connected")
112
+ })
113
+
114
+ test("plugin detail keeps refresh available for non-GitHub remotes", async () => {
115
+ const document = await renderSharePanel({
116
+ githubConnected: true,
117
+ gitInitialized: true,
118
+ hasCommit: true,
119
+ remoteUrl: "https://gitlab.com/octocat/demo-plugin.git",
120
+ remoteWebUrl: "",
121
+ hasPublished: true,
122
+ })
123
+ const staticActions = document.querySelector("[data-plugin-share-static-actions]")
124
+
125
+ assert.ok(!staticActions.classList.contains("task-hidden"))
126
+ assert.equal(visibleTextMatches(document, "a,button", "Check again").length, 1)
127
+ assert.equal(visibleTextMatches(document, "a,button", "View on GitHub").length, 0)
128
+ })
129
+
130
+ test("plugin detail does not duplicate refresh when auth is disconnected but a remote exists", async () => {
131
+ const document = await renderSharePanel({
132
+ githubConnected: false,
133
+ gitInitialized: true,
134
+ hasCommit: true,
135
+ remoteUrl: "git@github.com:octocat/demo-plugin.git",
136
+ remoteWebUrl: "https://github.com/octocat/demo-plugin",
137
+ hasPublished: true,
138
+ })
139
+
140
+ assert.equal(visibleTextMatches(document, "a,button", "Check again").length, 1)
141
+ assert.equal(visibleTextMatches(document, "a,button", "View on GitHub").length, 1)
142
+ assert.equal(document.querySelector("[data-plugin-share-note]").textContent, "Connected")
143
+ })
144
+
145
+ test("plugin action event panel completion closes the modal and refreshes after close", async () => {
146
+ let closeCount = 0
147
+ let fireOptions = null
148
+ let actionIframe = null
149
+ let reloadCount = 0
150
+ const unexpectedJsdomErrors = []
151
+ const virtualConsole = new VirtualConsole()
152
+ virtualConsole.on("jsdomError", (error) => {
153
+ if (error && /Not implemented: navigation/.test(error.message || "")) {
154
+ reloadCount += 1
155
+ return
156
+ }
157
+ unexpectedJsdomErrors.push(error)
158
+ })
159
+ const document = await renderSharePanel({}, {
160
+ actionMarkup: '<button type="button" data-plugin-action="install">Install</button>',
161
+ virtualConsole,
162
+ beforeScript(window) {
163
+ window.Swal = {
164
+ fire(options) {
165
+ fireOptions = options
166
+ const popup = window.document.createElement("div")
167
+ popup.innerHTML = options.html || ""
168
+ window.document.body.appendChild(popup)
169
+ actionIframe = popup.querySelector("iframe")
170
+ if (typeof options.didOpen === "function") {
171
+ options.didOpen(popup)
172
+ }
173
+ return Promise.resolve({})
174
+ },
175
+ close() {
176
+ closeCount += 1
177
+ if (fireOptions && typeof fireOptions.didClose === "function") {
178
+ fireOptions.didClose()
179
+ }
180
+ },
181
+ }
182
+ },
183
+ })
184
+ const window = document.defaultView
185
+
186
+ document.querySelector("[data-plugin-action='install']").click()
187
+ assert.ok(fireOptions)
188
+ assert.match(fireOptions.html, /\/action\/install\/plugin\/demo\/pinokio\.js/)
189
+ assert.match(fireOptions.html, /__pinokio_event_panel=1/)
190
+ assert.ok(actionIframe && actionIframe.contentWindow)
191
+
192
+ window.dispatchEvent(new window.MessageEvent("message", {
193
+ origin: window.location.origin,
194
+ data: {
195
+ e: "pinokio:event-panel-status",
196
+ success: true,
197
+ },
198
+ }))
199
+ assert.equal(closeCount, 0)
200
+ assert.equal(reloadCount, 0)
201
+
202
+ window.dispatchEvent(new window.MessageEvent("message", {
203
+ origin: window.location.origin,
204
+ source: actionIframe.contentWindow,
205
+ data: {
206
+ e: "pinokio:event-panel-status",
207
+ success: true,
208
+ },
209
+ }))
210
+
211
+ assert.equal(closeCount, 1)
212
+ assert.equal(reloadCount, 1)
213
+ assert.deepEqual(unexpectedJsdomErrors, [])
214
+ })
215
+
216
+ test("plugin delete confirmation and failure do not request SweetAlert icons", async () => {
217
+ const fireOptions = []
218
+ const document = await renderSharePanel({
219
+ localLabel: "plugin/demo",
220
+ }, {
221
+ actionMarkup: '<button type="button" data-plugin-delete>Delete</button>',
222
+ beforeScript(window) {
223
+ window.fetch = () => Promise.resolve({
224
+ ok: false,
225
+ status: 400,
226
+ json: () => Promise.resolve({ ok: false, error: "Plugin path is required." }),
227
+ })
228
+ window.Swal = {
229
+ fire(options) {
230
+ fireOptions.push(options)
231
+ return Promise.resolve(fireOptions.length === 1 ? { isConfirmed: true } : {})
232
+ },
233
+ }
234
+ },
235
+ })
236
+
237
+ document.querySelector("[data-plugin-delete]").click()
238
+ await new Promise((resolve) => setTimeout(resolve, 0))
239
+ await new Promise((resolve) => setTimeout(resolve, 0))
240
+
241
+ assert.equal(fireOptions.length, 2)
242
+ assert.equal(fireOptions[0].icon, undefined)
243
+ assert.equal(fireOptions[0].title, "Delete Demo Plugin?")
244
+ assert.match(fireOptions[0].html, /plugin\/demo/)
245
+ assert.equal(fireOptions[0].confirmButtonText, "Delete plugin")
246
+ assert.equal(fireOptions[1].icon, undefined)
247
+ assert.equal(fireOptions[1].title, "Delete failed")
248
+ assert.equal(fireOptions[1].text, "Plugin path is required.")
249
+ assert.equal(fireOptions[1].confirmButtonText, "Close")
250
+ assert.equal(fireOptions[1].buttonsStyling, false)
251
+ })
252
+
253
+ test("terminal action view uses generic event panel completion", async () => {
254
+ const terminalView = await fs.readFile(path.join(repoRoot, "server/views/terminal.ejs"), "utf8")
255
+ const disconnectBranchStart = terminalView.indexOf("packet.type === 'disconnect'")
256
+ const eventBranchStart = terminalView.indexOf('packet.type === "event"', disconnectBranchStart)
257
+ const eventBranchEnd = terminalView.indexOf("//this.socket.close()", eventBranchStart)
258
+
259
+ assert.ok(disconnectBranchStart >= 0)
260
+ assert.ok(eventBranchStart > disconnectBranchStart)
261
+ assert.ok(eventBranchEnd > eventBranchStart)
262
+
263
+ assert.match(terminalView, /pinokio:event-panel-status/)
264
+ assert.match(terminalView.slice(disconnectBranchStart, eventBranchStart), /pinokio:event-panel-status/)
265
+ assert.match(terminalView.slice(eventBranchStart, eventBranchEnd), /eventPanelStopPending = true/)
266
+ assert.doesNotMatch(terminalView.slice(eventBranchStart, eventBranchEnd), /pinokio:event-panel-status/)
267
+ assert.doesNotMatch(terminalView, /pluginActionCompletion/)
268
+ assert.doesNotMatch(terminalView, /pinokio:plugin-action-complete/)
269
+ })
@@ -0,0 +1,160 @@
1
+ const assert = require("node:assert/strict")
2
+ const path = require("node:path")
3
+ const test = require("node:test")
4
+ const ejs = require("ejs")
5
+ const { JSDOM } = require("jsdom")
6
+
7
+ const viewPath = path.resolve(__dirname, "..", "server/views/plugin_detail.ejs")
8
+
9
+ function basePlugin(overrides = {}) {
10
+ return {
11
+ title: "Demo Plugin",
12
+ description: "Demo plugin description.",
13
+ link: "https://example.com/plugin",
14
+ image: "",
15
+ icon: "fa-solid fa-terminal",
16
+ pluginPath: "/plugin/demo/pinokio.js",
17
+ extraParams: [],
18
+ defaultCwd: "",
19
+ hasInstall: true,
20
+ hasUpdate: true,
21
+ hasUninstall: true,
22
+ hasInstalledCheck: true,
23
+ installed: true,
24
+ category: "cli",
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ function basePluginUi(overrides = {}) {
30
+ return {
31
+ badges: [{ label: "Local plugin", tone: "accent" }],
32
+ launchSummary: "Launches inside Pinokio",
33
+ hasChanges: false,
34
+ changePreview: [],
35
+ extraChangeCount: 0,
36
+ localChangesCopy: "",
37
+ showSidebar: true,
38
+ sourceLabel: "Local folder",
39
+ sourceValue: "plugin/demo",
40
+ statusLabel: "Status",
41
+ statusValue: "Not version tracked yet",
42
+ canOpenFolder: false,
43
+ canManageSource: true,
44
+ isManagedSource: false,
45
+ githubPanelTitle: "GitHub",
46
+ githubPanelCopy: "",
47
+ remoteLabel: "",
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ function baseShareState(overrides = {}) {
53
+ return {
54
+ ownership: "local",
55
+ manageable: true,
56
+ canOpenFolder: false,
57
+ dir: "/tmp/pinokio/plugin/demo",
58
+ localLabel: "plugin/demo",
59
+ remoteUrl: "",
60
+ remoteWebUrl: "",
61
+ githubConnected: false,
62
+ gitInitialized: false,
63
+ hasCommit: false,
64
+ changeCount: 0,
65
+ changes: [],
66
+ branch: "HEAD",
67
+ commitUrl: "",
68
+ createUrl: "",
69
+ pushUrl: "",
70
+ ...overrides,
71
+ }
72
+ }
73
+
74
+ async function renderPluginDetail({ plugin = {}, pluginUi = {}, shareState = {} } = {}) {
75
+ const html = await ejs.renderFile(viewPath, {
76
+ theme: "dark",
77
+ agent: "browser",
78
+ list: [],
79
+ current_host: "",
80
+ showPeerAccess: false,
81
+ plugin: basePlugin(plugin),
82
+ pluginUi: basePluginUi(pluginUi),
83
+ pluginCwd: "",
84
+ shareState: baseShareState(shareState),
85
+ apps: [],
86
+ })
87
+ return new JSDOM(html).window.document
88
+ }
89
+
90
+ test("plugin detail omits the right column for built-in system plugins", async () => {
91
+ const document = await renderPluginDetail({
92
+ plugin: {
93
+ title: "Antigravity CLI Auto",
94
+ pluginPath: "/pinokio/run/plugin/antigravity-cli-auto/pinokio.js",
95
+ },
96
+ pluginUi: {
97
+ badges: [{ label: "Built-in plugin", tone: "neutral" }],
98
+ showSidebar: false,
99
+ },
100
+ shareState: {
101
+ ownership: "system",
102
+ localLabel: "plugin/antigravity-cli-auto/pinokio.js",
103
+ manageable: false,
104
+ },
105
+ })
106
+
107
+ assert.equal(document.querySelector(".task-detail-sidebar"), null)
108
+ assert.ok(document.querySelector(".task-detail-layout-single"))
109
+ assert.equal(document.querySelector("[data-plugin-share-next-title]"), null)
110
+ assert.doesNotMatch(document.body.textContent, /Plugin status/)
111
+ assert.match(document.querySelector(".task-header-actions").textContent, /Website/)
112
+ assert.doesNotMatch(document.querySelector(".task-header-actions").textContent, /Back/)
113
+ assert.equal(document.querySelector("[data-plugin-delete]"), null)
114
+ assert.equal(document.querySelector('.plugin-detail-breadcrumb a[href="/plugins"]')?.textContent, "Plugins")
115
+ })
116
+
117
+ test("plugin detail keeps source management column for local plugins", async () => {
118
+ const document = await renderPluginDetail()
119
+
120
+ assert.ok(document.querySelector(".task-detail-sidebar"))
121
+ assert.equal(document.querySelector(".task-detail-layout-single"), null)
122
+ assert.ok(document.querySelector("[data-plugin-share-next-title]"))
123
+ assert.match(document.body.textContent, /Plugin status/)
124
+ assert.equal(document.querySelector('.plugin-detail-breadcrumb a[href="/plugins"]')?.textContent, "Plugins")
125
+ assert.equal(
126
+ document.querySelector('.plugin-detail-breadcrumb a[aria-current="page"]')?.getAttribute("href"),
127
+ "/plugin?path=%2Fplugin%2Fdemo%2Fpinokio.js"
128
+ )
129
+ assert.match(document.querySelector(".task-header-actions").textContent, /Delete/)
130
+ assert.ok(document.querySelector(".task-header-actions [data-plugin-delete]"))
131
+ assert.doesNotMatch(document.querySelector(".task-header-actions").textContent, /Back/)
132
+ assert.equal(document.querySelectorAll(".plugin-detail-status-row").length, 3)
133
+ assert.equal(document.querySelectorAll(".task-detail-sidebar .task-meta-chip").length, 0)
134
+ assert.equal(
135
+ Array.from(document.querySelectorAll(".task-detail-sidebar h2")).some((heading) => heading.textContent.trim() === "GitHub"),
136
+ false
137
+ )
138
+ })
139
+
140
+ test("plugin detail does not show delete for managed non-local plugin source", async () => {
141
+ const document = await renderPluginDetail({
142
+ pluginUi: {
143
+ badges: [{ label: "Managed by Pinokio", tone: "neutral" }],
144
+ canManageSource: false,
145
+ isManagedSource: true,
146
+ sourceLabel: "Managed folder",
147
+ sourceValue: "plugin/code/demo",
148
+ githubPanelTitle: "Managed by Pinokio",
149
+ githubPanelCopy: "Managed plugin source.",
150
+ },
151
+ shareState: {
152
+ ownership: "managed",
153
+ manageable: false,
154
+ dir: "/tmp/pinokio/plugin/code/demo",
155
+ localLabel: "plugin/code/demo",
156
+ },
157
+ })
158
+
159
+ assert.equal(document.querySelector("[data-plugin-delete]"), null)
160
+ })
@@ -32,6 +32,25 @@ test("resolveLauncherPluginSelection returns app-dev plugin query paths", () =>
32
32
  )
33
33
  })
34
34
 
35
+ test("systemPluginPathForLocalPath maps local-shaped plugin paths to system candidates", () => {
36
+ assert.strictEqual(
37
+ PluginSources.systemPluginPathForLocalPath("/plugin/antigravity-cli-auto/pinokio.js"),
38
+ "/pinokio/run/plugin/antigravity-cli-auto/pinokio.js"
39
+ )
40
+ assert.strictEqual(
41
+ PluginSources.systemPluginPathForLocalPath("plugin/antigravity-cli-auto"),
42
+ "/pinokio/run/plugin/antigravity-cli-auto/pinokio.js"
43
+ )
44
+ assert.strictEqual(
45
+ PluginSources.systemPluginPathForLocalPath("/pinokio/run/plugin/antigravity-cli-auto/pinokio.js"),
46
+ ""
47
+ )
48
+ assert.strictEqual(
49
+ PluginSources.systemPluginPathForLocalPath("/api/example/plugins/helper/pinokio.js"),
50
+ ""
51
+ )
52
+ })
53
+
35
54
  test("normalizeActionPathComponents maps action URLs back to filesystem roots", () => {
36
55
  assert.deepStrictEqual(
37
56
  PluginSources.normalizeActionPathComponents(["pinokio", "run", "plugin", "antigravity-cli", "pinokio.js"]),
@@ -0,0 +1,238 @@
1
+ const assert = require('node:assert/strict')
2
+ const fs = require('node:fs/promises')
3
+ const os = require('node:os')
4
+ const path = require('node:path')
5
+ const test = require('node:test')
6
+
7
+ const Socket = require('../server/socket')
8
+ const Util = require('../kernel/util')
9
+
10
+ function createSocket(home) {
11
+ const kernel = {
12
+ homedir: home,
13
+ path: (...parts) => path.resolve(home, ...parts),
14
+ exists: async (targetPath) => {
15
+ try {
16
+ await fs.access(targetPath)
17
+ return true
18
+ } catch (_) {
19
+ return false
20
+ }
21
+ }
22
+ }
23
+ return Object.assign(Object.create(Socket.prototype), {
24
+ parent: { kernel },
25
+ rawLog: {},
26
+ logMeta: {},
27
+ sessions: {}
28
+ })
29
+ }
30
+
31
+ test('plugin source without cwd writes to home-relative logs', async () => {
32
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
33
+ try {
34
+ const socket = createSocket(root)
35
+ const key = path.join(root, 'plugin', 'oc', 'pinokio.js')
36
+
37
+ assert.equal(
38
+ await socket.resolveLogDir(key),
39
+ path.join(root, 'logs', 'plugin', 'oc', 'pinokio.js')
40
+ )
41
+ } finally {
42
+ await fs.rm(root, { recursive: true, force: true })
43
+ }
44
+ })
45
+
46
+ test('plugin source with cwd=undefined does not create repo-relative undefined logs', async () => {
47
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
48
+ try {
49
+ const socket = createSocket(root)
50
+ const key = `${path.join(root, 'plugin', 'oc', 'pinokio.js')}?cwd=undefined`
51
+
52
+ assert.equal(
53
+ await socket.resolveLogDir(key),
54
+ path.join(root, 'logs', 'plugin', 'oc', 'pinokio.js')
55
+ )
56
+ } finally {
57
+ await fs.rm(root, { recursive: true, force: true })
58
+ }
59
+ })
60
+
61
+ test('plugin source with app cwd writes app-local dev logs', async () => {
62
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
63
+ try {
64
+ const socket = createSocket(root)
65
+ const appRoot = path.join(root, 'api', 'example')
66
+ const key = `${path.join(root, 'plugin', 'oc', 'pinokio.js')}?cwd=${encodeURIComponent(appRoot)}`
67
+
68
+ assert.equal(
69
+ await socket.resolveLogDir(key),
70
+ path.join(appRoot, 'logs', 'dev', 'plugin', 'oc', 'pinokio.js')
71
+ )
72
+ } finally {
73
+ await fs.rm(root, { recursive: true, force: true })
74
+ }
75
+ })
76
+
77
+ test('future top-level source with app cwd writes app-local dev logs', async () => {
78
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
79
+ try {
80
+ const socket = createSocket(root)
81
+ const appRoot = path.join(root, 'api', 'example')
82
+ const key = `${path.join(root, 'tasks', 'build', 'run.js')}?cwd=${encodeURIComponent(appRoot)}`
83
+
84
+ assert.equal(
85
+ await socket.resolveLogDir(key),
86
+ path.join(appRoot, 'logs', 'dev', 'tasks', 'build', 'run.js')
87
+ )
88
+ } finally {
89
+ await fs.rm(root, { recursive: true, force: true })
90
+ }
91
+ })
92
+
93
+ test('global source with non-app cwd writes home-relative logs', async () => {
94
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
95
+ try {
96
+ const socket = createSocket(root)
97
+ const workspace = path.join(root, 'workspaces', 'tmp')
98
+ const key = `${path.join(root, 'plugin', 'oc', 'pinokio.js')}?cwd=${encodeURIComponent(workspace)}`
99
+
100
+ assert.equal(
101
+ await socket.resolveLogDir(key),
102
+ path.join(root, 'logs', 'plugin', 'oc', 'pinokio.js')
103
+ )
104
+ } finally {
105
+ await fs.rm(root, { recursive: true, force: true })
106
+ }
107
+ })
108
+
109
+ test('app script source writes app-local api logs', async () => {
110
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
111
+ try {
112
+ const socket = createSocket(root)
113
+ const key = path.join(root, 'api', 'example', 'start.js')
114
+
115
+ assert.equal(
116
+ await socket.resolveLogDir(key),
117
+ path.join(root, 'api', 'example', 'logs', 'api', 'start.js')
118
+ )
119
+ } finally {
120
+ await fs.rm(root, { recursive: true, force: true })
121
+ }
122
+ })
123
+
124
+ test('app script source uses nested pinokio app root when present', async () => {
125
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
126
+ try {
127
+ const socket = createSocket(root)
128
+ await fs.mkdir(path.join(root, 'api', 'example', 'pinokio'), { recursive: true })
129
+ const key = path.join(root, 'api', 'example', 'start.js')
130
+
131
+ assert.equal(
132
+ await socket.resolveLogDir(key),
133
+ path.join(root, 'api', 'example', 'pinokio', 'logs', 'api', 'start.js')
134
+ )
135
+ } finally {
136
+ await fs.rm(root, { recursive: true, force: true })
137
+ }
138
+ })
139
+
140
+ test('future top-level source writes home-relative logs without a special case', async () => {
141
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
142
+ try {
143
+ const socket = createSocket(root)
144
+ const key = path.join(root, 'tasks', 'build', 'run.js')
145
+
146
+ assert.equal(
147
+ await socket.resolveLogDir(key),
148
+ path.join(root, 'logs', 'tasks', 'build', 'run.js')
149
+ )
150
+ } finally {
151
+ await fs.rm(root, { recursive: true, force: true })
152
+ }
153
+ })
154
+
155
+ test('api-like top-level source is not treated as an app script', async () => {
156
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
157
+ try {
158
+ const socket = createSocket(root)
159
+ const key = path.join(root, 'apiary', 'run.js')
160
+
161
+ assert.equal(
162
+ await socket.resolveLogDir(key),
163
+ path.join(root, 'logs', 'apiary', 'run.js')
164
+ )
165
+ } finally {
166
+ await fs.rm(root, { recursive: true, force: true })
167
+ }
168
+ })
169
+
170
+ test('event and output logging use the same terminal log resolver', async () => {
171
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
172
+ try {
173
+ const socket = createSocket(root)
174
+ const key = path.join(root, 'plugin', 'oc', 'pinokio.js')
175
+
176
+ await socket.appendEventLog(key, { source: 'api', method: 'notify' }, 'event')
177
+ socket.sessions[key] = 'session-1'
178
+ await socket.log_buffer(key, 'hello\n', { source: 'api', method: 'shell.run' })
179
+
180
+ const dir = await socket.resolveLogDir(key)
181
+ assert.match(await fs.readFile(path.join(dir, 'events'), 'utf8'), /event/)
182
+ assert.match(await fs.readFile(path.join(dir, 'latest'), 'utf8'), /hello/)
183
+ } finally {
184
+ await fs.rm(root, { recursive: true, force: true })
185
+ }
186
+ })
187
+
188
+ test('plugin output with invalid cwd writes latest under home-relative logs', async () => {
189
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
190
+ try {
191
+ const socket = createSocket(root)
192
+ const key = `${path.join(root, 'plugin', 'oc', 'pinokio.js')}?cwd=undefined`
193
+ socket.sessions[key] = 'session-1'
194
+
195
+ await socket.log_buffer(key, 'hello\n', { source: 'api', method: 'shell.run' })
196
+
197
+ const latest = await fs.readFile(path.join(root, 'logs', 'plugin', 'oc', 'pinokio.js', 'latest'), 'utf8')
198
+ assert.match(latest, /\[api shell\.run\]/)
199
+ assert.match(latest, /hello/)
200
+ } finally {
201
+ await fs.rm(root, { recursive: true, force: true })
202
+ }
203
+ })
204
+
205
+ test('absolute sources outside PINOKIO_HOME do not get guessed log paths', async () => {
206
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
207
+ const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-external-'))
208
+ try {
209
+ const socket = createSocket(root)
210
+
211
+ assert.equal(
212
+ await socket.resolveLogDir(path.join(outside, 'run.js')),
213
+ null
214
+ )
215
+ } finally {
216
+ await fs.rm(root, { recursive: true, force: true })
217
+ await fs.rm(outside, { recursive: true, force: true })
218
+ }
219
+ })
220
+
221
+ test('legacy shell workspace logs remain workspace-owned', async () => {
222
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-terminal-log-'))
223
+ const workspace = process.platform === 'win32'
224
+ ? await fs.mkdtemp(path.join(os.tmpdir(), 'pinokioterminalworkspace-'))
225
+ : await fs.mkdtemp('/tmp/pinokioterminalworkspace-')
226
+ try {
227
+ const socket = createSocket(root)
228
+ const key = `shell/${Util.p2u(workspace)}_0`
229
+
230
+ assert.equal(
231
+ await socket.resolveLogDir(key),
232
+ path.join(workspace, 'logs', 'shell')
233
+ )
234
+ } finally {
235
+ await fs.rm(root, { recursive: true, force: true })
236
+ await fs.rm(workspace, { recursive: true, force: true })
237
+ }
238
+ })