wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/PiAgentUI/PiAgentUi Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 4
This commit is contained in:
1 parent
b3489e1ede
commit
98b5db029f
70 files changed
+9466
-9191
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# PiAgentUI desktop E2E tests
|
||||
|
||||
This suite uses Tauri's official WebDriver path (`tauri-driver`) with WebdriverIO.
|
||||
|
||||
## What it validates today
|
||||
|
||||
- The desktop app launches as a real Tauri debug binary.
|
||||
- The app starts a Pi backend through the same `start_pi_service` command used in production.
|
||||
- Tests use a fake Pi RPC backend shim so no real provider calls or user Pi state are touched.
|
||||
- App mode receives private `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` values.
|
||||
- First-run provider onboarding appears when no provider auth exists.
|
||||
- Provider catalog search works.
|
||||
- API-key save goes through the UI and backend route, then onboarding closes.
|
||||
|
||||
## Local setup
|
||||
|
||||
Install the Tauri WebDriver bridge once:
|
||||
|
||||
```sh
|
||||
cargo install tauri-driver --locked
|
||||
```
|
||||
|
||||
Run the suite:
|
||||
|
||||
```sh
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
If a debug Tauri binary already exists, skip the build step:
|
||||
|
||||
```sh
|
||||
npm run test:e2e:skip-build
|
||||
```
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
- `TAURI_DRIVER` — explicit path to `tauri-driver`.
|
||||
- `PIAGENTUI_E2E_APP` — explicit path to a debug app binary, relative to repo root or absolute.
|
||||
- `PIAGENTUI_E2E_KEEP_TMP=1` — keep temp app/runtime dirs for debugging.
|
||||
- `PIAGENTUI_E2E_LOG_LEVEL=info` — increase WebdriverIO logging.
|
||||
|
||||
## Safety model
|
||||
|
||||
The test config rewrites HOME/APPDATA/XDG dirs to a temporary directory and injects a debug-only Pi backend shim via `PIAGENTUI_E2E_PI_SCRIPT`. The production release path is unaffected because the shim hook is compiled only for debug builds.
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const agentDir = process.env.PI_CODING_AGENT_DIR
|
||||
const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR
|
||||
const markerDir = process.env.PIAGENTUI_E2E_MARKER_DIR
|
||||
|
||||
if (!agentDir || !sessionDir) {
|
||||
console.error('PI_CODING_AGENT_DIR and PI_CODING_AGENT_SESSION_DIR are required')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
fs.mkdirSync(agentDir, { recursive: true })
|
||||
fs.mkdirSync(sessionDir, { recursive: true })
|
||||
if (markerDir) fs.mkdirSync(markerDir, { recursive: true })
|
||||
|
||||
const token = `e2e-${crypto.randomUUID()}`
|
||||
let apiKeyConfigured = false
|
||||
let lastApiKeyLength
|
||||
const requests = []
|
||||
|
||||
const providers = () => [
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode Zen',
|
||||
models: [
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek V4 Flash',
|
||||
providerId: 'opencode',
|
||||
providerName: 'OpenCode Zen',
|
||||
},
|
||||
],
|
||||
auth: { method: 'apikey', authorization: { configured: apiKeyConfigured } },
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
models: [{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', providerId: 'anthropic', providerName: 'Anthropic' }],
|
||||
auth: { method: 'oauth', authorization: { configured: false } },
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
models: [{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', providerId: 'google', providerName: 'Google' }],
|
||||
auth: { method: 'apikey', authorization: { configured: false } },
|
||||
},
|
||||
]
|
||||
|
||||
function writeMarker(extra = {}) {
|
||||
if (!markerDir) return
|
||||
fs.writeFileSync(
|
||||
path.join(markerDir, 'fake-pi-state.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: process.pid,
|
||||
agentDir,
|
||||
sessionDir,
|
||||
startupArgs: process.argv.slice(2),
|
||||
apiKeyConfigured,
|
||||
lastApiKeyLength,
|
||||
requests,
|
||||
...extra,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
if (!raw) return {}
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
||||
requests.push({ method: req.method, path: url.pathname })
|
||||
writeMarker()
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/global/health') {
|
||||
return sendJson(res, 200, { ok: true, mode: 'rpc' })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/health') {
|
||||
return sendJson(res, 200, { status: 'ok', version: 'e2e' })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/providers') {
|
||||
return sendJson(res, 200, { providers: providers(), default: {} })
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/providers/opencode/api-key') {
|
||||
const body = await readBody(req)
|
||||
apiKeyConfigured = Boolean(body.apiKey)
|
||||
lastApiKeyLength = typeof body.apiKey === 'string' ? body.apiKey.length : 0
|
||||
writeMarker()
|
||||
return sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/providers/opencode/logout') {
|
||||
apiKeyConfigured = false
|
||||
writeMarker()
|
||||
return sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/models') {
|
||||
return sendJson(res, 200, { models: providers().flatMap(provider => provider.models) })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/sessions') {
|
||||
return sendJson(res, 200, { sessions: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/runtime/capabilities') {
|
||||
return sendJson(res, 200, { commands: true, extensions: true, providerAuth: true })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/commands') {
|
||||
return sendJson(res, 200, { commands: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/agents') {
|
||||
return sendJson(res, 200, { agents: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/skills') {
|
||||
return sendJson(res, 200, { skills: {} })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/mcp/status') {
|
||||
return sendJson(res, 200, { servers: {}, tools: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/network/access') {
|
||||
return sendJson(res, 200, { lanAccessEnabled: false, urls: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname.startsWith('/api/settings/')) {
|
||||
return sendJson(res, 200, {})
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: `Unhandled fake Pi route: ${req.method} ${url.pathname}` })
|
||||
})
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No TCP address')
|
||||
const discovery = { port: address.port, token, pid: process.pid, startedAt: new Date().toISOString() }
|
||||
fs.writeFileSync(path.join(agentDir, 'piagentui-port.json'), JSON.stringify(discovery, null, 2), 'utf8')
|
||||
writeMarker({ port: address.port })
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => server.close(() => process.exit(0)))
|
||||
process.on('SIGINT', () => server.close(() => process.exit(0)))
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const markerPath = path.join(process.env.PIAGENTUI_E2E_MARKER_DIR ?? '', 'fake-pi-state.json')
|
||||
|
||||
async function readMarker() {
|
||||
await browser.waitUntil(() => fs.existsSync(markerPath), {
|
||||
timeout: 15000,
|
||||
timeoutMsg: `Fake Pi marker was not created at ${markerPath}`,
|
||||
})
|
||||
return JSON.parse(fs.readFileSync(markerPath, 'utf8'))
|
||||
}
|
||||
|
||||
async function textExists(text) {
|
||||
const element = await $(`//*[contains(normalize-space(), ${JSON.stringify(text)})]`)
|
||||
return element.isExisting()
|
||||
}
|
||||
|
||||
describe('PiAgentUI first-run desktop onboarding', () => {
|
||||
before(async () => {
|
||||
await browser
|
||||
.waitUntil(async () => (await browser.getUrl()) !== 'about:blank', { timeout: 10000 })
|
||||
.catch(async () => {
|
||||
await browser.url('http://tauri.localhost/')
|
||||
})
|
||||
})
|
||||
it('starts an isolated Pi backend and opens the provider catalog', async () => {
|
||||
await browser.waitUntil(() => textExists('Connect a model provider'), {
|
||||
timeout: 45000,
|
||||
timeoutMsg: 'Provider onboarding did not appear',
|
||||
})
|
||||
|
||||
assert.equal(await textExists('First run setup'), true)
|
||||
assert.equal(await textExists('OpenCode Zen'), true)
|
||||
assert.equal(await textExists('DeepSeek V4 Flash'), true)
|
||||
assert.equal(await textExists('Anthropic'), true)
|
||||
assert.equal(await textExists('Google'), true)
|
||||
assert.equal(await textExists('API key'), true)
|
||||
assert.equal(await textExists('OAuth'), true)
|
||||
|
||||
const marker = await readMarker()
|
||||
assert.match(marker.agentDir, /pi-runtime/)
|
||||
assert.match(marker.agentDir, /agent/)
|
||||
assert.equal(marker.sessionDir, path.join(marker.agentDir, 'sessions'))
|
||||
assert.equal(marker.startupArgs.includes('--extension'), true)
|
||||
assert.equal(
|
||||
marker.startupArgs.some(arg => arg.endsWith('piagentui-server.ts')),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
marker.requests.some(request => request.path === '/api/providers'),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('filters providers, saves an API key, and exits first-run onboarding', async () => {
|
||||
const search = await $('input[placeholder*="Search providers"]')
|
||||
await search.setValue('claude')
|
||||
assert.equal(await textExists('Anthropic'), true)
|
||||
assert.equal(await textExists('OpenCode Zen'), false)
|
||||
|
||||
await search.setValue('deepseek v4 flash')
|
||||
assert.equal(await textExists('DeepSeek V4 Flash'), true)
|
||||
assert.equal(await textExists('OpenCode Zen'), true)
|
||||
|
||||
const addKey = await $('//button[normalize-space()="Add key"]')
|
||||
await addKey.click()
|
||||
|
||||
const keyInput = await $('input[placeholder="Paste API key"]')
|
||||
await keyInput.setValue('test-key-from-e2e')
|
||||
await $('//button[normalize-space()="Save key"]').click()
|
||||
|
||||
await browser.waitUntil(async () => !(await textExists('Connect a model provider')), {
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'Onboarding did not close after saving provider auth',
|
||||
})
|
||||
|
||||
const marker = await readMarker()
|
||||
assert.equal(marker.apiKeyConfigured, true)
|
||||
assert.equal(marker.lastApiKeyLength, 'test-key-from-e2e'.length)
|
||||
assert.equal(
|
||||
marker.requests.some(request => request.path === '/api/providers/opencode/api-key'),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const fakePiScript = path.join(__dirname, 'fixtures', 'fake-pi.mjs')
|
||||
const hostHome = os.homedir()
|
||||
const tempRoot = process.env.PIAGENTUI_E2E_TEMP_ROOT
|
||||
? path.resolve(process.env.PIAGENTUI_E2E_TEMP_ROOT)
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), 'piagentui-e2e-'))
|
||||
process.env.PIAGENTUI_E2E_TEMP_ROOT = tempRoot
|
||||
const markerDir = path.join(tempRoot, 'markers')
|
||||
const appHome = path.join(tempRoot, 'home')
|
||||
const appData = path.join(appHome, 'AppData', 'Roaming')
|
||||
const localAppData = path.join(appHome, 'AppData', 'Local')
|
||||
const xdgDataHome = path.join(appHome, '.local', 'share')
|
||||
const xdgConfigHome = path.join(appHome, '.config')
|
||||
const xdgCacheHome = path.join(appHome, '.cache')
|
||||
const e2eAgentDir = path.join(tempRoot, 'pi-runtime', 'agent')
|
||||
const e2eSessionDir = path.join(tempRoot, 'pi-runtime', 'agent', 'sessions')
|
||||
|
||||
for (const dir of [
|
||||
markerDir,
|
||||
appHome,
|
||||
appData,
|
||||
localAppData,
|
||||
xdgDataHome,
|
||||
xdgConfigHome,
|
||||
xdgCacheHome,
|
||||
e2eAgentDir,
|
||||
e2eSessionDir,
|
||||
]) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
process.env.PIAGENTUI_E2E_MARKER_DIR = markerDir
|
||||
|
||||
const appEnv = {
|
||||
...process.env,
|
||||
HOME: appHome,
|
||||
USERPROFILE: appHome,
|
||||
APPDATA: appData,
|
||||
LOCALAPPDATA: localAppData,
|
||||
XDG_DATA_HOME: xdgDataHome,
|
||||
XDG_CONFIG_HOME: xdgConfigHome,
|
||||
XDG_CACHE_HOME: xdgCacheHome,
|
||||
PIAGENTUI_E2E_NODE: process.execPath,
|
||||
PIAGENTUI_E2E_PI_SCRIPT: fakePiScript,
|
||||
PIAGENTUI_E2E_MARKER_DIR: markerDir,
|
||||
PI_CODING_AGENT_DIR: e2eAgentDir,
|
||||
PI_CODING_AGENT_SESSION_DIR: e2eSessionDir,
|
||||
}
|
||||
|
||||
let tauriDriver
|
||||
let driverExitExpected = false
|
||||
|
||||
function appBinaryPath() {
|
||||
if (process.env.PIAGENTUI_E2E_APP) return path.resolve(projectRoot, process.env.PIAGENTUI_E2E_APP)
|
||||
|
||||
const exe = process.platform === 'win32' ? 'piagentui.exe' : 'piagentui'
|
||||
return path.join(projectRoot, 'src-tauri', 'target', 'debug', exe)
|
||||
}
|
||||
|
||||
function e2eConfigPath() {
|
||||
return path.join(path.dirname(appBinaryPath()), 'piagentui-e2e.json')
|
||||
}
|
||||
|
||||
function tauriDriverPath() {
|
||||
if (process.env.TAURI_DRIVER) return process.env.TAURI_DRIVER
|
||||
const exe = process.platform === 'win32' ? 'tauri-driver.exe' : 'tauri-driver'
|
||||
const cargoPath = path.join(hostHome, '.cargo', 'bin', exe)
|
||||
return fs.existsSync(cargoPath) ? cargoPath : exe
|
||||
}
|
||||
|
||||
function nativeDriverArgs() {
|
||||
const explicit = process.env.TAURI_NATIVE_DRIVER
|
||||
if (explicit) return ['--native-driver', explicit]
|
||||
|
||||
if (process.platform !== 'win32') return []
|
||||
const candidates = [
|
||||
path.join(projectRoot, 'msedgedriver.exe'),
|
||||
path.join(projectRoot, '..', 'msedgedriver.exe'),
|
||||
path.join(process.cwd(), 'msedgedriver.exe'),
|
||||
]
|
||||
const driver = candidates.find(candidate => fs.existsSync(candidate))
|
||||
if (driver) console.info(`Using native WebDriver: ${driver}`)
|
||||
return driver ? ['--native-driver', driver] : []
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
...options,
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed with status ${result.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
driverExitExpected = true
|
||||
tauriDriver?.kill()
|
||||
fs.rmSync(e2eConfigPath(), { force: true })
|
||||
if (!process.env.PIAGENTUI_E2E_KEEP_TMP) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
runner: 'local',
|
||||
hostname: '127.0.0.1',
|
||||
port: 4444,
|
||||
specs: [path.join(projectRoot, 'e2e', 'specs', '**', '*.mjs')],
|
||||
maxInstances: 1,
|
||||
capabilities: [
|
||||
{
|
||||
browserName: 'wry',
|
||||
maxInstances: 1,
|
||||
'tauri:options': {
|
||||
application: appBinaryPath(),
|
||||
},
|
||||
},
|
||||
],
|
||||
logLevel: process.env.PIAGENTUI_E2E_LOG_LEVEL ?? 'warn',
|
||||
reporters: ['spec'],
|
||||
framework: 'mocha',
|
||||
mochaOpts: {
|
||||
ui: 'bdd',
|
||||
timeout: 120000,
|
||||
},
|
||||
waitforTimeout: 15000,
|
||||
connectionRetryTimeout: 120000,
|
||||
connectionRetryCount: 1,
|
||||
|
||||
onPrepare() {
|
||||
if (!process.env.PIAGENTUI_E2E_SKIP_BUILD) {
|
||||
run('pnpm', ['tauri', 'build', '--debug', '--no-bundle'])
|
||||
}
|
||||
|
||||
const application = appBinaryPath()
|
||||
if (!fs.existsSync(application)) {
|
||||
throw new Error(`Tauri debug binary not found: ${application}`)
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
e2eConfigPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.execPath,
|
||||
script: fakePiScript,
|
||||
markerDir,
|
||||
agentDir: e2eAgentDir,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const driver = tauriDriverPath()
|
||||
tauriDriver = spawn(driver, nativeDriverArgs(), {
|
||||
stdio: ['ignore', process.stdout, process.stderr],
|
||||
env: appEnv,
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
|
||||
tauriDriver.on('error', error => {
|
||||
console.error('tauri-driver error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
tauriDriver.on('exit', code => {
|
||||
if (!driverExitExpected) {
|
||||
console.error('tauri-driver exited unexpectedly with code:', code)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async afterTest(_test, _context, result) {
|
||||
if (result.passed) return
|
||||
|
||||
const artifactsDir = path.join(tempRoot, 'artifacts')
|
||||
fs.mkdirSync(artifactsDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const handles = await browser.getWindowHandles()
|
||||
const details = []
|
||||
for (const handle of handles) {
|
||||
await browser.switchToWindow(handle)
|
||||
details.push({ handle, url: await browser.getUrl(), title: await browser.getTitle() })
|
||||
}
|
||||
fs.writeFileSync(path.join(artifactsDir, 'windows.json'), JSON.stringify(details, null, 2), 'utf8')
|
||||
fs.writeFileSync(path.join(artifactsDir, 'source.html'), await browser.getPageSource(), 'utf8')
|
||||
const diagnostics = await browser.execute(() => ({
|
||||
href: window.location.href,
|
||||
webdriver: navigator.webdriver,
|
||||
hasPiBootData: 'PI_BOOT_DATA' in window,
|
||||
piBootData: window.PI_BOOT_DATA ?? null,
|
||||
hasTauriInternals: '__TAURI_INTERNALS__' in window,
|
||||
serviceConfig: window.localStorage.getItem('piagentui-service-config'),
|
||||
}))
|
||||
fs.writeFileSync(path.join(artifactsDir, 'diagnostics.json'), JSON.stringify(diagnostics, null, 2), 'utf8')
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(artifactsDir, 'browser-logs.json'),
|
||||
JSON.stringify(await browser.getLogs('browser'), null, 2),
|
||||
'utf8',
|
||||
)
|
||||
} catch {}
|
||||
await browser.saveScreenshot(path.join(artifactsDir, 'failure.png'))
|
||||
console.error(`E2E failure artifacts: ${artifactsDir}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to write E2E failure artifacts:', error)
|
||||
}
|
||||
},
|
||||
|
||||
after() {
|
||||
const marker = path.join(markerDir, 'fake-pi-state.json')
|
||||
if (!fs.existsSync(marker)) {
|
||||
throw new Error(`Fake Pi marker was not written: ${marker}`)
|
||||
}
|
||||
},
|
||||
|
||||
onComplete() {
|
||||
cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
|
||||
process.once(signal, () => {
|
||||
cleanup()
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user