pinokiod 8.2.33 → 8.2.35

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.
@@ -36,22 +36,24 @@ test('before any device joins, the ready state is the connect card alone, with s
36
36
  const h = render(t, { phase: 'ready', peers: [] })
37
37
  const card = h.document.querySelector('[data-state=ready] .anywhere-card')
38
38
  assert.equal(h.document.getElementById('anywhere-details').hidden, true, 'apps, devices and details wait for a device')
39
- assert.ok(card.querySelector('.anywhere-signout[data-action=logout]'), 'a wrong-account sign-in is fixable during onboarding')
39
+ assert.ok(h.document.querySelector('.anywhere-account-line .anywhere-signout[data-action=logout]'), 'a wrong-account sign-in is fixable during onboarding')
40
40
  assert.ok(!h.document.querySelector('#anywhere-details [data-action=logout]'), 'sign out lives with the account, not in details')
41
41
  assert.equal(card.querySelectorAll('ol, h3').length, 0, 'no step lists on the desk: the phone page explains Tailscale after the scan')
42
42
  const connect = h.document.getElementById('anywhere-connect')
43
- assert.equal(connect.dataset.device, 'phone', 'the QR is the default')
44
- assert.match(connect.querySelector('[data-panel=phone]').textContent, /Scan with your phone\./)
45
- const computer = connect.querySelector('[data-panel=computer]')
46
- assert.match(computer.textContent, /signing in as me@example.com/)
47
- const choose = connect.querySelector('input[value=computer]')
48
- choose.checked = true
49
- choose.dispatchEvent(new h.document.defaultView.Event('change'))
50
- assert.equal(connect.dataset.device, 'computer', 'the segmented control switches the panel')
51
- const link = computer.querySelector('#anywhere-address')
52
- assert.equal(link.getAttribute('href'), connectUrl, 'the computer path is the same link the QR carries')
53
- assert.equal(link.textContent, 'pinokio.co/anywhere/connect#…', 'the fragment is not spelled out')
54
- assert.equal(h.text('anywhere-account'), 'me@example.com')
43
+ const cards = connect.querySelectorAll('.anywhere-device-card')
44
+ assert.equal(cards.length, 2, 'both ways in, side by side, nothing behind a toggle')
45
+ for (const card of cards) {
46
+ const icon = card.querySelector('.anywhere-device-icon')
47
+ assert.ok(icon, 'each card leads with its own icon row')
48
+ assert.strictEqual(icon.nextElementSibling.tagName, 'H2')
49
+ }
50
+ assert.match(cards[0].textContent, /Connect a phone or tablet.*Scan with your phone\./s)
51
+ assert.ok(cards[0].querySelector('#anywhere-qr'))
52
+ assert.match(cards[1].textContent, /Connect another computer.*installing Tailscale and signing in as me@example\.com/s)
53
+ const link = cards[1].querySelector('#anywhere-address')
54
+ assert.equal(link.getAttribute('href'), connectUrl, 'the same link the QR carries')
55
+ assert.equal(link.textContent, 'pinokio.co/anywhere/connect#…')
56
+ assert.equal(h.document.querySelector('.anywhere-segments'), null)
55
57
  assert.match(h.text('anywhere-waiting'), /Waiting for another device/)
56
58
  assert.equal(h.document.getElementById('anywhere-expiry-warning').hidden, true)
57
59
  })
@@ -1,171 +0,0 @@
1
- /*
2
- Forwarders for Remote from computer to computer
3
- (spec/requirements/pinokio-anywhere.md, Other Pinokios; the routes in
4
- spec/requirements/pinokio-anywhere-api.md, Peers).
5
-
6
- The bundled tailscaled runs in userspace networking mode, so the browser on
7
- this computer has no route to tailnet addresses. The daemon does, and
8
- exposes it as a plain HTTP proxy on loopback (--outbound-http-proxy-listen).
9
- A forwarder is one loopback port that relays HTTP requests and upgrades
10
- through that proxy, with TLS to one peer's .ts.net name on one remote port.
11
- The peer sees an ordinary tailnet client; the browser talks plain HTTP to
12
- 127.0.0.1, as it does to Pinokio itself.
13
-
14
- Every forwarder answers /.pinokio/open itself, never relaying it: a peer on
15
- this revision rewrites the links inside its interface to that path, and the
16
- redirect lands on this node's `open` route, which maps the port on demand.
17
- */
18
- const http = require('http')
19
- const https = require('https')
20
- const httpProxy = require('http-proxy')
21
- const { ProxyAgent } = require('proxy-agent')
22
- const { OPEN_PATH } = require('./serve')
23
-
24
- const FORWARDED_HEADER = 'x-pinokio-forwarded'
25
- // honoured by the peer middleware's trust; a forwarder never sends them
26
- const STRIPPED_HEADERS = ['x-pinokio-peer', 'x-pinokio-source-host', 'x-pinokio-source-proto']
27
-
28
- // This node's `open` route for a peer and port, relative for the page's links.
29
- function openPath(label, port, to) {
30
- return `/anywhere/peers/${encodeURIComponent(label)}/open?port=${Number(port)}&to=${encodeURIComponent(to || '/')}`
31
- }
32
-
33
- // The headers go out as the incoming request's headers. Setting them in http-proxy's proxyReq
34
- // hook instead would throw: with the proxy agent the hook fires once the CONNECT completes, and
35
- // by then the outgoing headers are already written.
36
- function mark(req) {
37
- for (const name of STRIPPED_HEADERS) delete req.headers[name]
38
- req.headers[FORWARDED_HEADER] = '1'
39
- }
40
-
41
- class Forwarders {
42
- // proxyUrl: () => 'http://127.0.0.1:<port>' or null while the daemon is down
43
- // serverPort: () => pinokiod's own port
44
- // scheme exists for the tests, which forward to a plain local server instead of
45
- // TLS through the proxy; the product never passes it.
46
- constructor({ proxyUrl, serverPort, scheme = 'https' } = {}) {
47
- this.proxyUrl = proxyUrl
48
- this.serverPort = serverPort
49
- this.scheme = scheme
50
- this.agents = new Map() // proxy URL -> agent, one per daemon start
51
- this.servers = new Map() // "<dnsName>:<port>" -> { port, server, proxy, sockets } or { pending }
52
- }
53
- agent() {
54
- const url = this.proxyUrl()
55
- if (this.scheme === 'https' && !url) throw new Error('Remote is not running on this computer')
56
- if (!url) return undefined
57
- // proxy-agent picks CONNECT-then-TLS for an http:// proxy and an https target
58
- if (!this.agents.has(url)) this.agents.set(url, new ProxyAgent({ keepAlive: true, getProxyForUrl: () => url }))
59
- return this.agents.get(url)
60
- }
61
- // One request to a peer through the proxy, for the status probe and the launch/stop relay.
62
- async request(url, options) {
63
- return requestThroughProxy(this.agent(), url, options)
64
- }
65
- // Finds or starts the forwarder for (dnsName, port); resolves to its loopback port.
66
- async open(dnsName, port) {
67
- const key = `${String(dnsName).toLowerCase()}:${Number(port)}`
68
- const existing = this.servers.get(key)
69
- if (existing) return existing.port || (await existing.pending).port
70
- const pending = this.start(dnsName, Number(port))
71
- this.servers.set(key, { pending })
72
- try {
73
- const entry = await pending
74
- this.servers.set(key, entry)
75
- return entry.port
76
- } catch (error) {
77
- this.servers.delete(key)
78
- throw error
79
- }
80
- }
81
- async start(dnsName, port) {
82
- const label = String(dnsName).split('.')[0]
83
- const target = `${this.scheme}://${dnsName}:${port}`
84
- const proxy = httpProxy.createProxyServer({ target, agent: this.agent(), changeOrigin: true, secure: true, ws: true })
85
- proxy.on('error', (error, req, res) => {
86
- const message = `Could not reach ${dnsName}: ${error && error.message ? error.message : error}`
87
- if (res && typeof res.writeHead === 'function' && !res.headersSent) {
88
- res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
89
- res.end(message)
90
- } else if (res && typeof res.destroy === 'function') {
91
- res.destroy()
92
- }
93
- })
94
- const server = http.createServer((req, res) => {
95
- const url = new URL(req.url, 'http://127.0.0.1')
96
- if (url.pathname === OPEN_PATH) {
97
- const port = Number(url.searchParams.get('port'))
98
- if (!Number.isInteger(port) || port <= 0) {
99
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
100
- res.end('Not a Remote port')
101
- return
102
- }
103
- const route = openPath(label, port, url.searchParams.get('to') || '/')
104
- res.writeHead(302, { Location: `http://127.0.0.1:${this.serverPort()}${route}`, 'Cache-Control': 'no-store' })
105
- res.end()
106
- return
107
- }
108
- mark(req)
109
- proxy.web(req, res)
110
- })
111
- server.on('upgrade', (req, socket, head) => {
112
- mark(req)
113
- proxy.ws(req, socket, head)
114
- })
115
- // every connection on both sides, upgraded ones included, so stopAll can end a stream a
116
- // tab still holds; destroying only the browser's side would leave the peer's side piped open
117
- const sockets = new Set()
118
- const track = (socket) => {
119
- sockets.add(socket)
120
- socket.once('close', () => sockets.delete(socket))
121
- }
122
- server.on('connection', track)
123
- proxy.on('open', track)
124
- await new Promise((resolve, reject) => {
125
- server.once('error', reject)
126
- server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
127
- })
128
- return { port: server.address().port, server, proxy, sockets }
129
- }
130
- // Ends every forwarder, open streams included: they belong to the daemon that is going away.
131
- async stopAll() {
132
- const entries = [...this.servers.values()]
133
- this.servers.clear()
134
- for (const agent of this.agents.values()) if (typeof agent.destroy === 'function') agent.destroy()
135
- this.agents.clear()
136
- await Promise.all(entries.map(async (entry) => {
137
- const live = entry.server ? entry : await entry.pending.catch(() => null)
138
- if (!live) return
139
- live.proxy.close()
140
- for (const socket of live.sockets) socket.destroy()
141
- await new Promise((resolve) => live.server.close(resolve))
142
- }))
143
- }
144
- }
145
-
146
- // Resolves { status, headers, raw, body }; rejects on a transport failure or when the whole
147
- // request, the proxy's CONNECT included, exceeds the deadline.
148
- function requestThroughProxy(agent, url, { method = 'GET', headers = {}, body = null, timeoutMs = 4000 } = {}) {
149
- return new Promise((resolve, reject) => {
150
- const target = new URL(url)
151
- const lib = target.protocol === 'https:' ? https : http
152
- // the deadline is kept here, not as the request's signal: while the agent's CONNECT is still
153
- // pending the request has no socket, and Node reports an abort only once it gets one
154
- const deadline = setTimeout(() => { reject(new Error(`no answer in ${Math.round(timeoutMs / 1000)} s`)); req.destroy() }, timeoutMs)
155
- const req = lib.request(target, { method, headers, agent }, (res) => {
156
- const chunks = []
157
- res.on('data', (chunk) => chunks.push(chunk))
158
- res.on('end', () => {
159
- clearTimeout(deadline)
160
- const raw = Buffer.concat(chunks)
161
- resolve({ status: res.statusCode, headers: res.headers, raw, body: raw.toString('utf8') })
162
- })
163
- res.on('error', (error) => { clearTimeout(deadline); reject(error) })
164
- })
165
- req.on('error', (error) => { clearTimeout(deadline); reject(error) })
166
- if (body != null) req.write(body)
167
- req.end()
168
- })
169
- }
170
-
171
- module.exports = { Forwarders, openPath, FORWARDED_HEADER }
@@ -1,168 +0,0 @@
1
- const assert = require('node:assert/strict')
2
- const http = require('node:http')
3
- const test = require('node:test')
4
- const { once } = require('node:events')
5
- const WebSocket = require('ws')
6
- const { Forwarders } = require('../kernel/anywhere/forward')
7
-
8
- // Forwarders (spec/requirements/pinokio-anywhere.md, Other Pinokios; the
9
- // API spec, Peers): one loopback port relaying to one peer and port. The
10
- // peer here is a plain local server, so the TLS-through-proxy layer is
11
- // replaced by the http scheme and no agent; what is checked is the relay.
12
-
13
- async function peer(t) {
14
- const seen = []
15
- const server = http.createServer((req, res) => {
16
- seen.push({ url: req.url, headers: req.headers, method: req.method })
17
- let body = ''
18
- req.on('data', (chunk) => { body += chunk })
19
- req.on('end', () => {
20
- res.writeHead(200, { 'Content-Type': 'application/json' })
21
- res.end(JSON.stringify({ url: req.url, host: req.headers.host, forwarded: req.headers['x-pinokio-forwarded'] || null, peer: req.headers['x-pinokio-peer'] || null, body }))
22
- })
23
- })
24
- const wss = new WebSocket.Server({ server })
25
- wss.on('connection', (socket, req) => {
26
- socket.on('message', (message) => socket.send(`echo:${message} forwarded=${req.headers['x-pinokio-forwarded'] || 'none'} peer=${req.headers['x-pinokio-peer'] || 'none'}`))
27
- })
28
- server.listen(0, '127.0.0.1')
29
- await once(server, 'listening')
30
- t.after(() => new Promise((resolve) => { wss.close(); server.close(resolve); server.closeAllConnections() }))
31
- return { port: server.address().port, seen }
32
- }
33
-
34
- const get = (port, path, headers = {}) => new Promise((resolve, reject) => {
35
- http.get({ host: '127.0.0.1', port, path, headers }, (res) => {
36
- let body = ''
37
- res.on('data', (chunk) => { body += chunk })
38
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
39
- }).on('error', reject)
40
- })
41
-
42
- test('a forwarder relays requests with the forwarded header, the peer as Host, and no trusted peer headers', async (t) => {
43
- const target = await peer(t)
44
- const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
45
- t.after(() => forwarders.stopAll())
46
- const local = await forwarders.open('127.0.0.1', target.port)
47
- assert.equal(await forwarders.open('127.0.0.1', target.port), local, 'the same peer and port reuse the forwarder')
48
- const answer = await get(local, '/ui/?a=1', { 'X-Pinokio-Peer': '1', 'X-Pinokio-Source-Host': 'evil', 'X-Pinokio-Source-Proto': 'https' })
49
- assert.equal(answer.status, 200)
50
- const body = JSON.parse(answer.body)
51
- assert.equal(body.url, '/ui/?a=1')
52
- assert.equal(body.host, `127.0.0.1:${target.port}`, 'Host names the peer, not the loopback port')
53
- assert.equal(body.forwarded, '1')
54
- assert.equal(body.peer, null, 'the existing peer-trust headers never pass through')
55
- assert.equal(target.seen[0].headers['x-pinokio-source-host'], undefined)
56
- })
57
-
58
- // A forward proxy for plain http targets: the real ProxyAgent sends it absolute URLs, and
59
- // connects asynchronously, which is what broke the proxyReq-hook version in production.
60
- async function forwardProxy(t) {
61
- const server = http.createServer((req, res) => {
62
- const target = new URL(req.url)
63
- const upstream = http.request({ host: target.hostname, port: target.port, path: target.pathname + target.search, method: req.method, headers: req.headers }, (answer) => {
64
- res.writeHead(answer.statusCode, answer.headers)
65
- answer.pipe(res)
66
- })
67
- upstream.on('error', (error) => { res.writeHead(502); res.end(error.message) })
68
- req.pipe(upstream)
69
- })
70
- server.listen(0, '127.0.0.1')
71
- await once(server, 'listening')
72
- t.after(() => { server.closeAllConnections(); server.close() })
73
- return `http://127.0.0.1:${server.address().port}`
74
- }
75
-
76
- test('through the real proxy agent, whose connection completes later, the forwarded header still goes out and nothing throws', async (t) => {
77
- const target = await peer(t)
78
- const proxyUrl = await forwardProxy(t)
79
- const forwarders = new Forwarders({ proxyUrl: () => proxyUrl, serverPort: () => 42000, scheme: 'http' })
80
- t.after(() => forwarders.stopAll())
81
- const local = await forwarders.open('127.0.0.1', target.port)
82
- const answer = await get(local, '/ui/?a=1', { 'X-Pinokio-Peer': '1' })
83
- assert.equal(answer.status, 200)
84
- const body = JSON.parse(answer.body)
85
- assert.equal(body.forwarded, '1')
86
- assert.equal(body.peer, null)
87
- assert.equal(body.host, `127.0.0.1:${target.port}`)
88
- assert.equal(target.seen.length, 1)
89
- })
90
-
91
- test('two opens of the same peer and port at once share one forwarder', async (t) => {
92
- const target = await peer(t)
93
- const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
94
- t.after(() => forwarders.stopAll())
95
- const [a, b] = await Promise.all([forwarders.open('127.0.0.1', target.port), forwarders.open('127.0.0.1', target.port)])
96
- assert.ok(Number.isInteger(a) && a > 0)
97
- assert.equal(a, b)
98
- })
99
-
100
- test('a forwarder answers /.pinokio/open itself with a redirect to this node\'s open route', async (t) => {
101
- const target = await peer(t)
102
- const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
103
- t.after(() => forwarders.stopAll())
104
- const local = await forwarders.open('pinokio-1.tail1.ts.net', target.port)
105
- const answer = await get(local, '/.pinokio/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
106
- assert.equal(answer.status, 302)
107
- assert.equal(answer.headers.location, 'http://127.0.0.1:42000/anywhere/peers/pinokio-1/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
108
- assert.equal((await get(local, '/.pinokio/open?to=%2F')).status, 400, 'a link without a port does not bounce through pinokiod')
109
- assert.equal(target.seen.length, 0, 'never relayed')
110
- })
111
-
112
- test('a forwarder relays a WebSocket upgrade', async (t) => {
113
- const target = await peer(t)
114
- const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
115
- t.after(() => forwarders.stopAll())
116
- const local = await forwarders.open('127.0.0.1', target.port)
117
- const socket = new WebSocket(`ws://127.0.0.1:${local}/socket`, { headers: { 'X-Pinokio-Peer': '1' } })
118
- await once(socket, 'open')
119
- socket.send('hi')
120
- const [message] = await once(socket, 'message')
121
- assert.equal(String(message), 'echo:hi forwarded=1 peer=none', 'the forwarded header is added and the trust header dropped on the upgrade path too')
122
- socket.close()
123
- })
124
-
125
- test('a peer that cannot be reached answers 502, and stopAll closes every forwarder, an open WebSocket included', async (t) => {
126
- const target = await peer(t)
127
- const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
128
- const dead = await forwarders.open('127.0.0.1', 1)
129
- const answer = await get(dead, '/')
130
- assert.equal(answer.status, 502)
131
- assert.match(answer.body, /Could not reach 127\.0\.0\.1/)
132
- const live = await forwarders.open('127.0.0.1', target.port)
133
- const socket = new WebSocket(`ws://127.0.0.1:${live}/socket`)
134
- await once(socket, 'open')
135
- const closed = once(socket, 'close')
136
- const stopped = forwarders.stopAll()
137
- await Promise.race([stopped, new Promise((_, reject) => setTimeout(() => reject(new Error('stopAll hung on the open WebSocket')), 2000))])
138
- await closed
139
- await assert.rejects(get(live, '/'), /ECONNREFUSED/)
140
- })
141
-
142
- test('the https scheme refuses to forward while the daemon is down', () => {
143
- const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000 })
144
- assert.throws(() => forwarders.agent(), /Remote is not running/)
145
- })
146
-
147
- test('a request to a peer returns status and body, and the deadline covers the connection itself', async (t) => {
148
- const target = await peer(t)
149
- const plain = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
150
- const answer = await plain.request(`http://127.0.0.1:${target.port}/anywhere/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"id":"x"}' })
151
- assert.equal(answer.status, 200)
152
- assert.equal(JSON.parse(answer.body).body, '{"id":"x"}')
153
- const silent = http.createServer(() => { /* never answers */ })
154
- silent.listen(0, '127.0.0.1')
155
- await once(silent, 'listening')
156
- t.after(() => { silent.closeAllConnections(); silent.close() })
157
- await assert.rejects(plain.request(`http://127.0.0.1:${silent.address().port}/`, { timeoutMs: 200 }), /no answer in 0 s/)
158
- // a proxy that accepts and never answers the CONNECT, through the real agent: the deadline
159
- // still fires there, where a socket timeout would not have started yet
160
- const held = []
161
- const stuckProxy = require('node:net').createServer((socket) => { held.push(socket) /* never answers */ })
162
- stuckProxy.listen(0, '127.0.0.1')
163
- await once(stuckProxy, 'listening')
164
- t.after(() => { for (const socket of held) socket.destroy(); stuckProxy.close() })
165
- const forwarders = new Forwarders({ proxyUrl: () => `http://127.0.0.1:${stuckProxy.address().port}`, serverPort: () => 42000 })
166
- t.after(() => forwarders.stopAll())
167
- await assert.rejects(forwarders.request('https://peer.tail1.ts.net/anywhere/status', { timeoutMs: 300 }), /no answer in 0 s/)
168
- })
@@ -1,96 +0,0 @@
1
- const assert = require('node:assert/strict')
2
- const fs = require('node:fs')
3
- const path = require('node:path')
4
- const test = require('node:test')
5
- const ejs = require('ejs')
6
- const { JSDOM, VirtualConsole } = require('jsdom')
7
-
8
- // The Remote page's "Your other Pinokios" section (spec/requirements/
9
- // pinokio-anywhere.md, Other Pinokios): on a loopback host only, filled from
10
- // /anywhere/peers at each status poll while ready, stop relayed per app.
11
- const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
12
- const template = fs.readFileSync(filename, 'utf8')
13
- const address = 'https://pinokio.tail123.ts.net'
14
- const ready = { phase: 'ready', enabled: true, address, connectUrl: 'https://pinokio.co/anywhere/connect#u=x', dnsName: 'pinokio.tail123.ts.net', account: { email: 'me@example.com' }, peers: [{ name: 'STUDIO', os: 'windows', online: true }], apps: [], serve: [] }
15
-
16
- function render(t, { loopback, peers }) {
17
- const state = { calls: [], timers: [], peers }
18
- const html = ejs.render(template, { theme: 'light', agent: 'electron', status: ready, loopback }, { filename, includer: () => ({ template: ' ' }) })
19
- const virtualConsole = new VirtualConsole()
20
- virtualConsole.on('jsdomError', (error) => { throw error })
21
- const dom = new JSDOM(html, {
22
- url: 'http://127.0.0.1:42000/anywhere', runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole,
23
- beforeParse(window) {
24
- window.setTimeout = (callback, delay) => state.timers.push({ callback, delay })
25
- window.fetch = async (url, options = {}) => {
26
- state.calls.push({ url, method: options.method || 'GET', body: options.body })
27
- if (url === '/anywhere/status') return { ok: true, json: async () => ({ ...ready }) }
28
- if (url === '/anywhere/peers') return state.peersError ? { ok: false, status: 502, json: async () => ({ error: state.peersError }) } : { ok: true, json: async () => ({ peers: state.peers }) }
29
- if (state.stopFails) return { ok: false, status: 502, json: async () => ({ error: 'no answer in 30 s' }) }
30
- return { ok: true, json: async () => ({ action: 'stopped' }) }
31
- }
32
- window.open = () => {}
33
- },
34
- })
35
- t.after(() => dom.window.close())
36
- const document = dom.window.document
37
- // the poll: status, then the peers refresh it starts; let both settle
38
- const poll = async () => { state.timers.shift().callback(); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)) }
39
- return { state, document, poll, section: () => document.getElementById('anywhere-others') }
40
- }
41
-
42
- test('on a loopback host the section lists each other Pinokio with Open Pinokio and its apps through the open route', async (t) => {
43
- const h = render(t, { loopback: true, peers: [
44
- { name: 'pinokio-1', apps: [
45
- { id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' },
46
- { id: 'forge', name: 'Forge', state: 'starting', exposed: false, open: null },
47
- ] },
48
- { name: 'other', apps: [] },
49
- ] })
50
- assert.ok(h.section(), 'the card exists before the first answer')
51
- await h.poll()
52
- assert.deepEqual(h.state.calls.map((call) => call.url), ['/anywhere/peers', '/anywhere/status', '/anywhere/peers'], 'once on load, then with each status poll')
53
- const rows = [...h.section().querySelectorAll('[data-peer]')]
54
- assert.deepEqual(rows.map((row) => row.dataset.peer), ['pinokio-1', 'other'])
55
- const links = [...rows[0].querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')])
56
- assert.deepEqual(links, [['Open Pinokio', '/anywhere/peers/pinokio-1/open?port=443&to=%2F'], ['Open', '/anywhere/peers/pinokio-1/open?port=43000&to=%2F']])
57
- assert.match(rows[0].textContent, /Forge/)
58
- assert.ok(rows[0].querySelector('.anywhere-badge'), 'an app without an address shows its state instead of a link')
59
- assert.match(rows[1].textContent, /no app running/)
60
- assert.doesNotMatch(h.section().textContent, /Looking…/)
61
- })
62
-
63
- test('with none found the section says so, and a stop is relayed to the peer then refreshed', async (t) => {
64
- const h = render(t, { loopback: true, peers: [] })
65
- await h.poll()
66
- assert.match(h.section().textContent, /No other Pinokio with Remote on is online/)
67
- h.state.peers = [{ name: 'pinokio-1', apps: [{ id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' }] }]
68
- await h.poll()
69
- const stop = h.section().querySelector('button[data-stop="comfy"]')
70
- assert.ok(stop)
71
- stop.click()
72
- await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve))
73
- const posted = h.state.calls.find((call) => call.method === 'POST')
74
- assert.deepEqual([posted.url, posted.body], ['/anywhere/peers/pinokio-1/anywhere/apps/stop', '{"id":"comfy"}'])
75
- assert.equal(h.state.calls.filter((call) => call.url === '/anywhere/peers').length, 4, 'load, two polls, and once more after the stop')
76
- assert.equal(h.section().querySelector('.anywhere-error'), null)
77
- h.state.stopFails = true
78
- h.section().querySelector('button[data-stop="comfy"]').click()
79
- await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve))
80
- assert.match(h.section().querySelector('.anywhere-error').textContent, /Could not stop it on pinokio-1: no answer in 30 s/)
81
- assert.ok(h.section().querySelector('[data-peer="pinokio-1"]'), 'the list is still there under the note')
82
- })
83
-
84
- test('while the daemon runs without the proxy, the section shows the reason the server gives', async (t) => {
85
- const h = render(t, { loopback: true, peers: [] })
86
- h.state.peersError = 'Turn Remote off and on to reach your other Pinokios'
87
- await h.poll()
88
- assert.match(h.section().textContent, /Could not look for other Pinokios: Turn Remote off and on to reach your other Pinokios/)
89
- })
90
-
91
- test('through the node\'s own Tailscale name there is no section and no peers request', async (t) => {
92
- const h = render(t, { loopback: false, peers: [{ name: 'pinokio-1', apps: [] }] })
93
- assert.equal(h.section(), null)
94
- await h.poll()
95
- assert.deepEqual(h.state.calls.map((call) => call.url), ['/anywhere/status'], 'no peers request at load or at the poll')
96
- })