dsh-plugin-rack 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -9
- package/client.js +191 -84
- package/index.js +61 -51
- package/lib/spec.js +285 -0
- package/package.json +14 -6
- package/test/behaviour.mjs +0 -549
- package/test/download.mjs +0 -221
- package/test/job-background.mjs +0 -205
- package/test/pnpm-command.mjs +0 -49
- package/test/self-check.mjs +0 -124
package/test/download.mjs
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
// The downloader must survive the networks people actually have: a link that
|
|
2
|
-
// drops mid-file, an HTTP proxy, a SOCKS5 proxy, a proxy that is configured but
|
|
3
|
-
// dead, and none of the above. Everything here runs against local servers, so
|
|
4
|
-
// the test never touches the internet.
|
|
5
|
-
//
|
|
6
|
-
// Run with: node test/download.mjs
|
|
7
|
-
import { createServer } from 'node:http'
|
|
8
|
-
import { createServer as createNetServer, connect as netConnect } from 'node:net'
|
|
9
|
-
import { createHash } from 'node:crypto'
|
|
10
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
|
11
|
-
import { tmpdir } from 'node:os'
|
|
12
|
-
import { join } from 'node:path'
|
|
13
|
-
|
|
14
|
-
const home = mkdtempSync(join(tmpdir(), 'rack-dl-'))
|
|
15
|
-
process.env.DSH_HOME = home
|
|
16
|
-
mkdirSync(join(home, 'plugin-rack'), { recursive: true })
|
|
17
|
-
|
|
18
|
-
const mod = await import(new URL('../index.js', import.meta.url).href)
|
|
19
|
-
const { downloadFile, fetchAny } = mod
|
|
20
|
-
|
|
21
|
-
const SIZE = 2 * 1024 * 1024
|
|
22
|
-
const payload = Buffer.alloc(SIZE)
|
|
23
|
-
for (let i = 0; i < SIZE; i += 1) payload[i] = (i * 31 + 7) & 0xff
|
|
24
|
-
const digest = createHash('sha256').update(payload).digest('hex')
|
|
25
|
-
|
|
26
|
-
let failures = 0
|
|
27
|
-
function check(ok, label) {
|
|
28
|
-
if (ok) console.log('ok ' + label)
|
|
29
|
-
else {
|
|
30
|
-
failures += 1
|
|
31
|
-
console.error('FAIL ' + label)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function config(proxy) {
|
|
36
|
-
writeFileSync(join(home, 'plugin-rack', 'config.json'), JSON.stringify({ download: { proxy } }))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function listen(server) {
|
|
40
|
-
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// A server that drops the socket once, so the downloader has to resume.
|
|
44
|
-
let requests = 0
|
|
45
|
-
let rangeRequests = 0
|
|
46
|
-
const origin = createServer((req, res) => {
|
|
47
|
-
requests += 1
|
|
48
|
-
if (req.url === '/stable.bin') {
|
|
49
|
-
res.writeHead(200, { 'content-length': SIZE })
|
|
50
|
-
res.end(payload)
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
// A transfer that trickles, so a cancel can land in the middle of it.
|
|
54
|
-
if (req.url === '/slow.bin') {
|
|
55
|
-
res.writeHead(200, { 'content-length': SIZE })
|
|
56
|
-
let offset = 0
|
|
57
|
-
const step = 64 * 1024
|
|
58
|
-
const pump = () => {
|
|
59
|
-
if (offset >= SIZE) {
|
|
60
|
-
res.end()
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
res.write(payload.subarray(offset, offset + step))
|
|
64
|
-
offset += step
|
|
65
|
-
setTimeout(pump, 5)
|
|
66
|
-
}
|
|
67
|
-
pump()
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
const range = req.headers.range
|
|
71
|
-
if (typeof range === 'string') {
|
|
72
|
-
rangeRequests += 1
|
|
73
|
-
const start = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0)
|
|
74
|
-
const rest = payload.subarray(start)
|
|
75
|
-
res.writeHead(206, { 'content-length': rest.length, 'content-range': 'bytes ' + start + '-' + (SIZE - 1) + '/' + SIZE })
|
|
76
|
-
res.end(rest)
|
|
77
|
-
return
|
|
78
|
-
}
|
|
79
|
-
res.writeHead(200, { 'content-length': SIZE })
|
|
80
|
-
res.write(payload.subarray(0, 512 * 1024))
|
|
81
|
-
setTimeout(() => res.destroy(), 20)
|
|
82
|
-
})
|
|
83
|
-
const originPort = await listen(origin)
|
|
84
|
-
|
|
85
|
-
function fresh(name) {
|
|
86
|
-
return join(home, name + '.bin')
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
config('off')
|
|
90
|
-
process.env.NO_PROXY = ''
|
|
91
|
-
|
|
92
|
-
// 1. A dropped connection is resumed with a Range request.
|
|
93
|
-
{
|
|
94
|
-
const dest = fresh('resume')
|
|
95
|
-
const bytes = await downloadFile('http://127.0.0.1:' + originPort + '/asset.bin', dest)
|
|
96
|
-
const got = readFileSync(dest)
|
|
97
|
-
check(bytes === SIZE && createHash('sha256').update(got).digest('hex') === digest, 'resumes a dropped download')
|
|
98
|
-
check(rangeRequests >= 1, 'the resume asks for a byte range')
|
|
99
|
-
check(!existsSync(dest + '.part'), 'the working file is gone after success')
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// 2. An HTTP CONNECT proxy is used when it is configured.
|
|
103
|
-
let tunneled = 0
|
|
104
|
-
const proxy = createServer()
|
|
105
|
-
proxy.on('connect', (req, socket, head) => {
|
|
106
|
-
tunneled += 1
|
|
107
|
-
const [rawHost, rawPort] = req.url.split(':')
|
|
108
|
-
const host = rawHost === 'rack-origin.test' ? '127.0.0.1' : rawHost
|
|
109
|
-
const port = rawPort
|
|
110
|
-
const upstream = netConnect(Number(port), host, () => {
|
|
111
|
-
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
112
|
-
if (head.length > 0) upstream.write(head)
|
|
113
|
-
upstream.pipe(socket)
|
|
114
|
-
socket.pipe(upstream)
|
|
115
|
-
})
|
|
116
|
-
upstream.on('error', () => socket.destroy())
|
|
117
|
-
socket.on('error', () => upstream.destroy())
|
|
118
|
-
})
|
|
119
|
-
const proxyPort = await listen(proxy)
|
|
120
|
-
config('http://127.0.0.1:' + proxyPort)
|
|
121
|
-
{
|
|
122
|
-
const dest = fresh('proxy')
|
|
123
|
-
await downloadFile('http://127.0.0.1:' + originPort + '/asset.bin', dest)
|
|
124
|
-
check(existsSync(dest) && createHash('sha256').update(readFileSync(dest)).digest('hex') === digest, 'downloads through an HTTP proxy')
|
|
125
|
-
check(tunneled >= 1, 'the HTTP proxy was actually used')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// 3. A SOCKS5 proxy works too.
|
|
129
|
-
let socked = 0
|
|
130
|
-
const socks = createNetServer((socket) => {
|
|
131
|
-
let stage = 0
|
|
132
|
-
let buffer = Buffer.alloc(0)
|
|
133
|
-
socket.on('data', (chunk) => {
|
|
134
|
-
buffer = Buffer.concat([buffer, chunk])
|
|
135
|
-
if (stage === 0) {
|
|
136
|
-
if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return
|
|
137
|
-
buffer = buffer.subarray(2 + buffer[1])
|
|
138
|
-
socket.write(Buffer.from([0x05, 0x00]))
|
|
139
|
-
stage = 1
|
|
140
|
-
}
|
|
141
|
-
if (stage === 1) {
|
|
142
|
-
if (buffer.length < 5) return
|
|
143
|
-
const length = buffer[4]
|
|
144
|
-
if (buffer.length < 7 + length) return
|
|
145
|
-
const host = buffer.subarray(5, 5 + length).toString('utf8')
|
|
146
|
-
const port = buffer.readUInt16BE(5 + length)
|
|
147
|
-
buffer = buffer.subarray(7 + length)
|
|
148
|
-
stage = 2
|
|
149
|
-
const upstream = netConnect(port, host, () => {
|
|
150
|
-
socked += 1
|
|
151
|
-
socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
|
|
152
|
-
upstream.pipe(socket)
|
|
153
|
-
socket.pipe(upstream)
|
|
154
|
-
})
|
|
155
|
-
upstream.on('error', () => socket.destroy())
|
|
156
|
-
}
|
|
157
|
-
})
|
|
158
|
-
socket.on('error', () => {})
|
|
159
|
-
})
|
|
160
|
-
const socksPort = await listen(socks)
|
|
161
|
-
config('socks5://127.0.0.1:' + socksPort)
|
|
162
|
-
{
|
|
163
|
-
const dest = fresh('socks')
|
|
164
|
-
await downloadFile('http://127.0.0.1:' + originPort + '/asset.bin', dest)
|
|
165
|
-
check(existsSync(dest) && createHash('sha256').update(readFileSync(dest)).digest('hex') === digest, 'downloads through a SOCKS5 proxy')
|
|
166
|
-
check(socked >= 1, 'the SOCKS5 proxy was actually used')
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// 3b. fetchAny reaches a host only the proxy can resolve.
|
|
170
|
-
{
|
|
171
|
-
config('http://127.0.0.1:' + proxyPort)
|
|
172
|
-
const answer = await fetchAny('http://rack-origin.test:' + originPort + '/stable.bin')
|
|
173
|
-
check(answer.ok === true && (await answer.text()).length === SIZE, 'fetchAny goes through the proxy when the host does not resolve')
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// 4. A proxy that is configured but dead falls back to the direct connection.
|
|
177
|
-
config('http://127.0.0.1:9')
|
|
178
|
-
{
|
|
179
|
-
const dest = fresh('fallback')
|
|
180
|
-
const bytes = await downloadFile('http://127.0.0.1:' + originPort + '/asset.bin', dest)
|
|
181
|
-
check(bytes === SIZE, 'falls back to the direct connection when the proxy is dead')
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// 5. NO_PROXY is honoured: the proxy is not even tried.
|
|
185
|
-
config('http://127.0.0.1:9')
|
|
186
|
-
process.env.NO_PROXY = '127.0.0.1'
|
|
187
|
-
{
|
|
188
|
-
const dest = fresh('noproxy')
|
|
189
|
-
const before = requests
|
|
190
|
-
const bytes = await downloadFile('http://127.0.0.1:' + originPort + '/asset.bin', dest)
|
|
191
|
-
check(bytes === SIZE && requests > before, 'NO_PROXY skips the proxy')
|
|
192
|
-
}
|
|
193
|
-
process.env.NO_PROXY = ''
|
|
194
|
-
|
|
195
|
-
// 6. Cancelling mid-flight used to destroy the write stream while chunk
|
|
196
|
-
// callbacks were still queued; the resulting unhandled 'error' took the whole
|
|
197
|
-
// host process down. Five cancels in a row is the cheap way to notice a return.
|
|
198
|
-
for (let i = 0; i < 5; i += 1) {
|
|
199
|
-
const controller = new AbortController()
|
|
200
|
-
const dest = fresh('cancel-' + i)
|
|
201
|
-
const timer = setTimeout(() => controller.abort(new Error('cancelled')), 40)
|
|
202
|
-
let stopped = false
|
|
203
|
-
try {
|
|
204
|
-
await downloadFile('http://127.0.0.1:' + originPort + '/slow.bin', dest, controller.signal)
|
|
205
|
-
} catch {
|
|
206
|
-
stopped = true
|
|
207
|
-
} finally {
|
|
208
|
-
clearTimeout(timer)
|
|
209
|
-
}
|
|
210
|
-
check(stopped, 'cancel #' + (i + 1) + ' stops the transfer')
|
|
211
|
-
check(existsSync(dest) === false, 'cancel #' + (i + 1) + ' writes no finished file')
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
for (const server of [origin, proxy, socks]) server.close()
|
|
215
|
-
|
|
216
|
-
if (failures > 0) {
|
|
217
|
-
console.error('\nDOWNLOAD_FAILED')
|
|
218
|
-
process.exitCode = 1
|
|
219
|
-
} else {
|
|
220
|
-
console.log('\nDOWNLOAD_OK')
|
|
221
|
-
}
|
package/test/job-background.mjs
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
// A started update must survive the panel that started it going away.
|
|
2
|
-
//
|
|
3
|
-
// This drives the real host half over http, on a throwaway DSH_HOME, with a
|
|
4
|
-
// stub pluginManager: it opens POST /updates, kills the socket on purpose (that
|
|
5
|
-
// is what closing the panel does to the fetch), and then checks that
|
|
6
|
-
// - GET /job reports the run as still running right afterwards,
|
|
7
|
-
// - the run reaches a terminal state on its own,
|
|
8
|
-
// - job.json on disk carries that result for the next panel visit.
|
|
9
|
-
//
|
|
10
|
-
// Run with: node test/job-background.mjs
|
|
11
|
-
import { createServer } from 'node:http'
|
|
12
|
-
import { request as request_http } from 'node:http'
|
|
13
|
-
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
14
|
-
import { tmpdir } from 'node:os'
|
|
15
|
-
import { join } from 'node:path'
|
|
16
|
-
|
|
17
|
-
const home = mkdtempSync(join(tmpdir(), 'rack-job-'))
|
|
18
|
-
process.env.DSH_HOME = home
|
|
19
|
-
|
|
20
|
-
const profile = join(home, 'profiles', 'test')
|
|
21
|
-
mkdirSync(profile, { recursive: true })
|
|
22
|
-
// One exact-pinned dependency: the only kind an update is allowed to touch.
|
|
23
|
-
writeFileSync(
|
|
24
|
-
join(profile, 'package.json'),
|
|
25
|
-
JSON.stringify({ name: 'profile', dependencies: { 'rack-job-probe': '0.0.1' } }, null, 2),
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
const pluginManager = {
|
|
29
|
-
async registries() {
|
|
30
|
-
// Point at a closed local port: the registry lookup fails fast and the run
|
|
31
|
-
// terminates without ever touching pnpm or a real registry.
|
|
32
|
-
return { resolved: 'http://127.0.0.1:9/', registry: 'http://127.0.0.1:9/' }
|
|
33
|
-
},
|
|
34
|
-
async listBundles() {
|
|
35
|
-
return []
|
|
36
|
-
},
|
|
37
|
-
list() {
|
|
38
|
-
return []
|
|
39
|
-
},
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const registered = []
|
|
43
|
-
const ctx = {
|
|
44
|
-
pluginManager,
|
|
45
|
-
get(name) {
|
|
46
|
-
if (name === 'profileContext') return { dir: profile }
|
|
47
|
-
return undefined
|
|
48
|
-
},
|
|
49
|
-
effect(fn) {
|
|
50
|
-
fn()
|
|
51
|
-
},
|
|
52
|
-
webServer: {
|
|
53
|
-
register(route) {
|
|
54
|
-
registered.push(route)
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const mod = await import(new URL('../index.js', import.meta.url).href)
|
|
60
|
-
mod.apply(ctx)
|
|
61
|
-
|
|
62
|
-
const route = registered[0]
|
|
63
|
-
const server = createServer((req, res) => {
|
|
64
|
-
route.handler(req, res).catch(() => {
|
|
65
|
-
if (!res.headersSent) {
|
|
66
|
-
res.statusCode = 500
|
|
67
|
-
res.end('{}')
|
|
68
|
-
}
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
|
72
|
-
const port = server.address().port
|
|
73
|
-
const base = `http://127.0.0.1:${port}/api/dsh-plugin-rack`
|
|
74
|
-
|
|
75
|
-
function fail(message) {
|
|
76
|
-
console.error('FAIL ' + message)
|
|
77
|
-
server.close()
|
|
78
|
-
process.exitCode = 1
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function getJob() {
|
|
82
|
-
const response = await fetch(base + '/job')
|
|
83
|
-
const body = await response.json()
|
|
84
|
-
return body.job
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// 1. Nothing has run yet.
|
|
88
|
-
const first = await getJob()
|
|
89
|
-
if (first !== null) fail('GET /job should be null before any update')
|
|
90
|
-
else console.log('ok /job is empty before the first update')
|
|
91
|
-
|
|
92
|
-
// 1b. The same read carries the queue that the idle watcher works through.
|
|
93
|
-
const firstAnswer = await (await fetch(base + '/job')).json()
|
|
94
|
-
if (!Array.isArray(firstAnswer.queue) || firstAnswer.queue.length !== 0) fail('/job must report an empty queue')
|
|
95
|
-
else console.log('ok /job reports an empty queue')
|
|
96
|
-
|
|
97
|
-
// 2. Start an update and drop the connection the way a closed panel does.
|
|
98
|
-
await new Promise((resolve, reject) => {
|
|
99
|
-
const body = JSON.stringify({ names: ['rack-job-probe'] })
|
|
100
|
-
const call = request_http(
|
|
101
|
-
base + '/updates',
|
|
102
|
-
{
|
|
103
|
-
method: 'POST',
|
|
104
|
-
headers: {
|
|
105
|
-
'content-type': 'application/json',
|
|
106
|
-
'content-length': Buffer.byteLength(body),
|
|
107
|
-
// 写接口要求声明意图(面板自己发请求时也带这个头)。裸 POST 会被 403。
|
|
108
|
-
'x-dsh-plugin-rack': '1',
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
(response) => {
|
|
112
|
-
// A panel that is closed never reads this; drain and still destroy.
|
|
113
|
-
response.resume()
|
|
114
|
-
},
|
|
115
|
-
)
|
|
116
|
-
call.on('error', () => {})
|
|
117
|
-
call.write(body)
|
|
118
|
-
setTimeout(() => {
|
|
119
|
-
call.destroy()
|
|
120
|
-
resolve()
|
|
121
|
-
}, 120)
|
|
122
|
-
setTimeout(() => reject(new Error('the update call never returned')), 30000).unref()
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
// 3. The run is registered and reported as running.
|
|
126
|
-
let seenRunning = false
|
|
127
|
-
const deadline = Date.now() + 15000
|
|
128
|
-
let job = null
|
|
129
|
-
while (Date.now() < deadline) {
|
|
130
|
-
job = await getJob()
|
|
131
|
-
if (job !== null && job.running === true) {
|
|
132
|
-
seenRunning = true
|
|
133
|
-
break
|
|
134
|
-
}
|
|
135
|
-
await new Promise((done) => setTimeout(done, 100))
|
|
136
|
-
}
|
|
137
|
-
if (!seenRunning) fail('GET /job never reported the started update as running')
|
|
138
|
-
else console.log('ok /job reports the run while it is still going')
|
|
139
|
-
|
|
140
|
-
// 4. It reaches a terminal state without anyone reading the original response.
|
|
141
|
-
// Its own budget: the previous step may already have spent most of one.
|
|
142
|
-
const settleDeadline = Date.now() + 20000
|
|
143
|
-
while (Date.now() < settleDeadline) {
|
|
144
|
-
job = await getJob()
|
|
145
|
-
if (job !== null && job.finishedAt !== null) break
|
|
146
|
-
await new Promise((done) => setTimeout(done, 200))
|
|
147
|
-
}
|
|
148
|
-
if (job === null || job.finishedAt === null) fail('the update never settled after the client went away')
|
|
149
|
-
else console.log('ok the run finished with nobody listening')
|
|
150
|
-
|
|
151
|
-
if (job.ok !== false) fail('this probe must fail (its registry is a closed port), got ok=' + String(job.ok))
|
|
152
|
-
else console.log('ok the failure is reported, not swallowed')
|
|
153
|
-
|
|
154
|
-
// 5. The result is on disk for the next panel visit.
|
|
155
|
-
// The plugin keeps one state directory per profile (see stateDir in index.js),
|
|
156
|
-
// and the fake profile is basename(profile) === 'test'.
|
|
157
|
-
const stateDir = join(home, 'plugin-rack', 'profiles', 'test')
|
|
158
|
-
const jobFile = join(stateDir, 'job.json')
|
|
159
|
-
if (!existsSync(jobFile)) fail('job.json was not written')
|
|
160
|
-
else {
|
|
161
|
-
const onDisk = JSON.parse(readFileSync(jobFile, 'utf8'))
|
|
162
|
-
if (onDisk.id !== job.id || onDisk.finishedAt === null) fail('job.json does not match the reported run')
|
|
163
|
-
else console.log('ok job.json carries the finished run')
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// 6. Cancelling is only offered while a run exists, and only for `cancel`.
|
|
167
|
-
async function postJob(payload) {
|
|
168
|
-
const response = await fetch(base + '/job', {
|
|
169
|
-
method: 'POST',
|
|
170
|
-
headers: { 'content-type': 'application/json', 'x-dsh-plugin-rack': '1' },
|
|
171
|
-
body: JSON.stringify(payload),
|
|
172
|
-
})
|
|
173
|
-
return response.status
|
|
174
|
-
}
|
|
175
|
-
const idleCancel = await postJob({ action: 'cancel' })
|
|
176
|
-
if (idleCancel !== 409) fail('cancelling with no run must answer 409, got ' + idleCancel)
|
|
177
|
-
else console.log('ok cancelling an idle host answers 409')
|
|
178
|
-
const badCancel = await postJob({ action: 'boom' })
|
|
179
|
-
if (badCancel !== 400) fail('an unknown job action must answer 400, got ' + badCancel)
|
|
180
|
-
else console.log('ok an unknown job action answers 400')
|
|
181
|
-
|
|
182
|
-
// 7. A queued update is picked up on its own, without any request driving it.
|
|
183
|
-
// The probe's registry is a closed port, so this run must leave a failed job
|
|
184
|
-
// and an empty queue behind: that is the watcher doing the work.
|
|
185
|
-
const queueFile = join(stateDir, 'queue.json')
|
|
186
|
-
mkdirSync(stateDir, { recursive: true })
|
|
187
|
-
writeFileSync(queueFile, JSON.stringify([{ kind: 'updates', names: ['rack-job-probe'], queuedAt: new Date().toISOString() }], null, 2))
|
|
188
|
-
const queuedJobId = job === null ? null : job.id
|
|
189
|
-
let pumped = false
|
|
190
|
-
const pumpDeadline = Date.now() + 25000
|
|
191
|
-
while (Date.now() < pumpDeadline) {
|
|
192
|
-
const answer = await (await fetch(base + '/job')).json()
|
|
193
|
-
if (answer.job !== null && answer.job.id !== queuedJobId && answer.job.finishedAt !== null) {
|
|
194
|
-
pumped = true
|
|
195
|
-
if (Array.isArray(answer.queue) && answer.queue.length !== 0) fail('the finished queued run must leave an empty queue')
|
|
196
|
-
break
|
|
197
|
-
}
|
|
198
|
-
await new Promise((done) => setTimeout(done, 500))
|
|
199
|
-
}
|
|
200
|
-
if (!pumped) fail('the idle watcher never picked the queued update up')
|
|
201
|
-
else console.log('ok a queued update is applied by the idle watcher')
|
|
202
|
-
|
|
203
|
-
server.close()
|
|
204
|
-
if (process.exitCode === 1) console.error('\nJOB_BACKGROUND_FAILED')
|
|
205
|
-
else console.log('\nJOB_BACKGROUND_OK')
|
package/test/pnpm-command.mjs
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
// How pnpm gets started. A `.mjs` is run by node; a Windows wrapper (`pnpm.cmd`,
|
|
2
|
-
// from corepack or a standalone install) cannot be executed by node at all, and
|
|
3
|
-
// the cmd.exe quoting that fixes it is the part nobody can eyeball.
|
|
4
|
-
//
|
|
5
|
-
// Run with: node test/pnpm-command.mjs
|
|
6
|
-
import { pnpmInvocation } from '../index.js'
|
|
7
|
-
|
|
8
|
-
let failures = 0
|
|
9
|
-
function check(ok, label) {
|
|
10
|
-
if (ok) console.log('ok ' + label)
|
|
11
|
-
else {
|
|
12
|
-
failures += 1
|
|
13
|
-
console.error('FAIL ' + label)
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const execPath = 'C:\\Program Files\\nodejs\\node.exe'
|
|
18
|
-
|
|
19
|
-
const direct = pnpmInvocation('C:/npm/node_modules/pnpm/bin/pnpm.mjs', ['add', 'a@1.0.0'], execPath)
|
|
20
|
-
check(direct.command === execPath, 'a pnpm.mjs is run by node')
|
|
21
|
-
check(direct.commandArgs[0] === 'C:/npm/node_modules/pnpm/bin/pnpm.mjs', 'the script is the first argument')
|
|
22
|
-
check(direct.commandArgs.slice(1).join(' ') === 'add a@1.0.0', 'arguments are passed through unchanged')
|
|
23
|
-
check(direct.electron === false, 'a real node is not run as electron')
|
|
24
|
-
|
|
25
|
-
const electron = pnpmInvocation('C:/npm/pnpm.mjs', [], process.execPath)
|
|
26
|
-
check(electron.electron === true, 'the host binary is flagged for ELECTRON_RUN_AS_NODE')
|
|
27
|
-
|
|
28
|
-
const batch = pnpmInvocation('C:\\corepack\\shims\\pnpm.cmd', ['add', '@scope/pkg@1.0.0', '--save-exact'], execPath)
|
|
29
|
-
check(/cmd\.exe$/i.test(batch.command), 'a pnpm.cmd goes through cmd.exe')
|
|
30
|
-
const line = batch.commandArgs[batch.commandArgs.length - 1]
|
|
31
|
-
check(line.startsWith('""') && line.endsWith('""'), 'the whole command is wrapped once for /s')
|
|
32
|
-
check(line.includes('"C:\\corepack\\shims\\pnpm.cmd"'), 'the wrapper path is quoted')
|
|
33
|
-
check(line.includes('"@scope/pkg@1.0.0"'), 'a scoped spec stays a single argument')
|
|
34
|
-
check(line.includes('"--save-exact"'), 'flags stay quoted as well')
|
|
35
|
-
|
|
36
|
-
const powershell = pnpmInvocation('D:\\corepack\\pnpm.ps1', ['add', 'x@1'], execPath)
|
|
37
|
-
check(powershell.command === 'powershell.exe', 'a pnpm.ps1 goes through powershell')
|
|
38
|
-
check(powershell.commandArgs.includes('-File'), 'powershell is told to run a file')
|
|
39
|
-
|
|
40
|
-
const standalone = pnpmInvocation('C:\\Users\\me\\AppData\\Local\\pnpm\\pnpm.exe', ['install'], execPath)
|
|
41
|
-
check(standalone.command === 'C:\\Users\\me\\AppData\\Local\\pnpm\\pnpm.exe', 'a standalone pnpm.exe runs directly')
|
|
42
|
-
check(standalone.commandArgs.join(' ') === 'install', 'the exe gets the arguments as-is')
|
|
43
|
-
|
|
44
|
-
if (failures > 0) {
|
|
45
|
-
console.error('\nPNPM_COMMAND_FAILED')
|
|
46
|
-
process.exitCode = 1
|
|
47
|
-
} else {
|
|
48
|
-
console.log('\nPNPM_COMMAND_OK')
|
|
49
|
-
}
|
package/test/self-check.mjs
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Self-check for the plugin source. Run it before publishing:
|
|
3
|
-
//
|
|
4
|
-
// node test/self-check.mjs
|
|
5
|
-
//
|
|
6
|
-
// It verifies the things that silently break the panel: a translation key that
|
|
7
|
-
// exists in one language only, a route that the header comment does not list,
|
|
8
|
-
// a hook placed after an early return (that crashes the whole panel), and
|
|
9
|
-
// leftover debugging output.
|
|
10
|
-
|
|
11
|
-
import { readFileSync } from 'node:fs'
|
|
12
|
-
import { fileURLToPath } from 'node:url'
|
|
13
|
-
import { dirname, join } from 'node:path'
|
|
14
|
-
|
|
15
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
-
const root = join(here, '..')
|
|
17
|
-
const read = (name) => readFileSync(join(root, name), 'utf8')
|
|
18
|
-
|
|
19
|
-
const failures = []
|
|
20
|
-
const check = (label, ok, detail) => {
|
|
21
|
-
if (ok) {
|
|
22
|
-
console.log(' ok ' + label)
|
|
23
|
-
} else {
|
|
24
|
-
console.log(' FAIL ' + label + (detail === undefined ? '' : ' — ' + detail))
|
|
25
|
-
failures.push(label)
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const client = read('client.js')
|
|
30
|
-
const index = read('index.js')
|
|
31
|
-
|
|
32
|
-
console.log('i18n')
|
|
33
|
-
{
|
|
34
|
-
const start = client.indexOf('const DICT = {')
|
|
35
|
-
if (start < 0) {
|
|
36
|
-
check('DICT found', false)
|
|
37
|
-
} else {
|
|
38
|
-
const open = client.indexOf('{', start)
|
|
39
|
-
// The table ends at the line that closes the DICT assignment.
|
|
40
|
-
const end = client.indexOf('\n };', open)
|
|
41
|
-
const literal = client.slice(open, end + 6)
|
|
42
|
-
let table = null
|
|
43
|
-
try {
|
|
44
|
-
table = eval('(' + literal + ')')
|
|
45
|
-
} catch (error) {
|
|
46
|
-
table = null
|
|
47
|
-
check('DICT parses', false, error instanceof Error ? error.message : String(error))
|
|
48
|
-
}
|
|
49
|
-
if (table !== null) {
|
|
50
|
-
const en = Object.keys(table.en ?? {})
|
|
51
|
-
const zh = Object.keys(table.zh ?? {})
|
|
52
|
-
const onlyEn = en.filter((key) => !(key in (table.zh ?? {})))
|
|
53
|
-
const onlyZh = zh.filter((key) => !(key in (table.en ?? {})))
|
|
54
|
-
check('en/zh have the same key set', onlyEn.length === 0 && onlyZh.length === 0, 'en-only: ' + onlyEn.join(',') + ' zh-only: ' + onlyZh.join(','))
|
|
55
|
-
const used = new Set()
|
|
56
|
-
for (const match of client.matchAll(/\bt\('([A-Za-z0-9_]+)'/g)) used.add(match[1])
|
|
57
|
-
const missing = [...used].filter((key) => !(key in (table.en ?? {})))
|
|
58
|
-
check('every t(\'…\') key exists in the dictionary', missing.length === 0, missing.join(','))
|
|
59
|
-
console.log(' (' + en.length + ' keys per language, ' + used.size + ' referenced)')
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
console.log('routes')
|
|
65
|
-
{
|
|
66
|
-
const table = index.match(/const ROUTES = \{([\s\S]*?)\n\s*\}/)
|
|
67
|
-
const routes = new Set()
|
|
68
|
-
if (table !== null) {
|
|
69
|
-
for (const match of table[1].matchAll(/'([^']+)':\s*\[([^\]]*)\]/g)) {
|
|
70
|
-
for (const verb of match[2].split(',').map((item) => item.trim().replace(/'/g, ''))) {
|
|
71
|
-
routes.add(verb + ' ' + match[1])
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
check('ROUTES table parsed', routes.size > 0, String(routes.size))
|
|
76
|
-
// The header comment lists the routes as "GET /api/dsh-plugin-rack/<path>".
|
|
77
|
-
const header = index.slice(0, index.indexOf('*/'))
|
|
78
|
-
const documented = new Set()
|
|
79
|
-
for (const match of header.matchAll(/\b(GET|POST)\s+\/api\/dsh-plugin-rack\/([a-z]*)/g)) {
|
|
80
|
-
documented.add(match[1].toUpperCase() + ' /' + match[2])
|
|
81
|
-
}
|
|
82
|
-
const missing = [...routes].filter((route) => !documented.has(route))
|
|
83
|
-
check('header comment lists every route', missing.length === 0, missing.join(', '))
|
|
84
|
-
console.log(' (' + routes.size + ' routes, ' + documented.size + ' documented)')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
console.log('react hook order')
|
|
88
|
-
{
|
|
89
|
-
// The panel returns early while data is still loading. A hook below that
|
|
90
|
-
// return makes the hook count differ between renders and React tears the
|
|
91
|
-
// whole panel down, so nothing may be declared after the early returns.
|
|
92
|
-
const lines = client.split(/\r?\n/)
|
|
93
|
-
let earlyReturn = -1
|
|
94
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
95
|
-
if (/if \((error|data) === null\) \{/.test(lines[i]) || /if \(error !== null\) \{/.test(lines[i])) {
|
|
96
|
-
earlyReturn = i
|
|
97
|
-
break
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
check('early return located', earlyReturn >= 0)
|
|
101
|
-
const offenders = []
|
|
102
|
-
for (let i = earlyReturn + 1; i < lines.length; i += 1) {
|
|
103
|
-
if (/React\.use[A-Za-z]+\(/.test(lines[i])) offenders.push(i + 1 + ': ' + lines[i].trim())
|
|
104
|
-
}
|
|
105
|
-
check('no hook after the early return', offenders.length === 0, offenders.join(' | '))
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
console.log('leftovers')
|
|
109
|
-
{
|
|
110
|
-
for (const [name, source] of [['client.js', client], ['index.js', index]]) {
|
|
111
|
-
const debug = source.match(/\bconsole\.(log|debug|trace)\(|\bdebugger\b|\bTODO\b|\bFIXME\b/)
|
|
112
|
-
check(name + ' has no debugging leftovers', debug === null, debug === null ? undefined : debug[0])
|
|
113
|
-
}
|
|
114
|
-
check('client does not use a native confirm dialog', !/window\.confirm/.test(client))
|
|
115
|
-
check('the panel does not offer a route that the Host removed', !/['"]\/probe['"]/.test(client))
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
console.log()
|
|
119
|
-
if (failures.length === 0) {
|
|
120
|
-
console.log('SELF_CHECK_OK')
|
|
121
|
-
} else {
|
|
122
|
-
console.log('SELF_CHECK_FAILED: ' + failures.length + ' problem(s)')
|
|
123
|
-
process.exitCode = 1
|
|
124
|
-
}
|