Preview: Router fuer Dev-Server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d788dba937
commit
cf2841adf5
2 files changed
+334
-4
No files matched your search
@@ -59,6 +59,43 @@ export function publicSubdomainsFromHostsFile(content) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-Subdomains aus einer von syncPreviewTls geschriebenen preview-hosts.yml:
|
||||||
|
* alle HTTPS-Router, deren Service preview-runner@docker ist.
|
||||||
|
*/
|
||||||
|
export function devSubdomainsFromHostsFile(content) {
|
||||||
|
const result = new Set()
|
||||||
|
let current = null
|
||||||
|
for (const line of String(content || '').split('\n')) {
|
||||||
|
if (/^ {4}\S/.test(line)) {
|
||||||
|
const match = line.match(/^ {4}preview-([a-z0-9-]+)-https:\s*$/)
|
||||||
|
current = match ? match[1] : null
|
||||||
|
} else if (current && /^ {6}service:\s*preview-runner@docker\s*$/.test(line)) {
|
||||||
|
result.add(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projekte mit laufendem/schlafendem Dev-Server (nur mit Preview-Runner).
|
||||||
|
* Liefert null, wenn Appwrite nicht antwortet.
|
||||||
|
*/
|
||||||
|
async function getDevProjects() {
|
||||||
|
try {
|
||||||
|
const docs = await listDocuments(config.collections.websiteProjects, [
|
||||||
|
Query.equal('previewState', ['sleeping', 'starting', 'online']),
|
||||||
|
Query.limit(500),
|
||||||
|
])
|
||||||
|
return docs.filter(
|
||||||
|
(d) => d.subdomain && /^[a-z0-9-]+$/.test(d.subdomain) && String(d.previewDetected || '').startsWith('dev')
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[previewDeploy] getDevProjects fehlgeschlagen:', err.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPreviewConfig(repoFullName, branch) {
|
export async function fetchPreviewConfig(repoFullName, branch) {
|
||||||
const [owner, repo] = repoFullName.split('/')
|
const [owner, repo] = repoFullName.split('/')
|
||||||
if (!owner || !repo || !config.gitea.apiToken) {
|
if (!owner || !repo || !config.gitea.apiToken) {
|
||||||
@@ -341,12 +378,25 @@ export async function syncPreviewTls() {
|
|||||||
entries = []
|
entries = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const slugs = entries
|
const folderSlugs = entries
|
||||||
.filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name))
|
.filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name))
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.sort()
|
|
||||||
|
|
||||||
const hostsFile = path.join(outDir, 'preview-hosts.yml')
|
const hostsFile = path.join(outDir, 'preview-hosts.yml')
|
||||||
|
// Dev-Server (Preview-Runner): Hosts auch ohne Ordner, Service preview-runner
|
||||||
|
let devSet = new Set()
|
||||||
|
let devProjects = []
|
||||||
|
if (config.previewRunner.enabled) {
|
||||||
|
const found = await getDevProjects()
|
||||||
|
if (found) {
|
||||||
|
devProjects = found
|
||||||
|
devSet = new Set(found.map((d) => d.subdomain))
|
||||||
|
} else {
|
||||||
|
// Appwrite antwortet nicht: Dev-Hosts aus der letzten Datei uebernehmen
|
||||||
|
devSet = await fs.readFile(hostsFile, 'utf8').then(devSubdomainsFromHostsFile, () => new Set())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const slugs = [...new Set([...folderSlugs, ...devSet])].sort()
|
||||||
let publicSet = await getPublicSubdomains()
|
let publicSet = await getPublicSubdomains()
|
||||||
let publicSource = 'appwrite'
|
let publicSource = 'appwrite'
|
||||||
if (!publicSet) {
|
if (!publicSet) {
|
||||||
@@ -361,6 +411,7 @@ export async function syncPreviewTls() {
|
|||||||
const host = `${name}.${baseHost}`
|
const host = `${name}.${baseHost}`
|
||||||
const id = `preview-${name}`
|
const id = `preview-${name}`
|
||||||
const isPublic = publicSet.has(name)
|
const isPublic = publicSet.has(name)
|
||||||
|
const service = devSet.has(name) ? 'preview-runner@docker' : 'preview-nginx@docker'
|
||||||
// Oeffentliche Website: HTTPS-Router OHNE preview-auth -> frei zugaenglich.
|
// Oeffentliche Website: HTTPS-Router OHNE preview-auth -> frei zugaenglich.
|
||||||
// Preview (Standard): mit preview-chain (ForwardAuth) -> login-geschuetzt.
|
// Preview (Standard): mit preview-chain (ForwardAuth) -> login-geschuetzt.
|
||||||
const httpsMiddlewareLines = isPublic
|
const httpsMiddlewareLines = isPublic
|
||||||
@@ -371,7 +422,7 @@ export async function syncPreviewTls() {
|
|||||||
` rule: "Host(\`${host}\`)"`,
|
` rule: "Host(\`${host}\`)"`,
|
||||||
' entryPoints:',
|
' entryPoints:',
|
||||||
' - websecure',
|
' - websecure',
|
||||||
' service: preview-nginx@docker',
|
` service: ${service}`,
|
||||||
...httpsMiddlewareLines,
|
...httpsMiddlewareLines,
|
||||||
' priority: 100',
|
' priority: 100',
|
||||||
' tls:',
|
' tls:',
|
||||||
@@ -381,7 +432,7 @@ export async function syncPreviewTls() {
|
|||||||
` rule: "Host(\`${host}\`)"`,
|
` rule: "Host(\`${host}\`)"`,
|
||||||
' entryPoints:',
|
' entryPoints:',
|
||||||
' - web',
|
' - web',
|
||||||
' service: preview-nginx@docker',
|
` service: ${service}`,
|
||||||
' middlewares:',
|
' middlewares:',
|
||||||
' - preview-sites-redirect@docker',
|
' - preview-sites-redirect@docker',
|
||||||
' priority: 100',
|
' priority: 100',
|
||||||
@@ -398,9 +449,49 @@ export async function syncPreviewTls() {
|
|||||||
|
|
||||||
await fs.mkdir(outDir, { recursive: true })
|
await fs.mkdir(outDir, { recursive: true })
|
||||||
await fs.writeFile(hostsFile, content, 'utf8')
|
await fs.writeFile(hostsFile, content, 'utf8')
|
||||||
|
if (devProjects.length) registerDevSpecs(devProjects)
|
||||||
return { synced: true, hosts: slugs.length, publicSource }
|
return { synced: true, hosts: slugs.length, publicSource }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R12: Dev-Auftraege beim Runner hinterlegen (Hintergrund, nie blockierend/werfend)
|
||||||
|
let registering = null
|
||||||
|
|
||||||
|
/** Wartet auf eine laufende Registrierungsrunde (Tests). */
|
||||||
|
export async function waitForDevRegistration() {
|
||||||
|
while (registering) await registering
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDevSpecs(projects) {
|
||||||
|
if (registering) return
|
||||||
|
registering = (async () => {
|
||||||
|
const key = envKey()
|
||||||
|
await Promise.allSettled(
|
||||||
|
projects.map(async (project) => {
|
||||||
|
const sub = project.subdomain
|
||||||
|
try {
|
||||||
|
const repo = project.repoFullName
|
||||||
|
if (!repo) return
|
||||||
|
const branch = project.previewBranch || 'main'
|
||||||
|
let sha = project.previewBuiltSha
|
||||||
|
if (!SHA_RE.test(String(sha || ''))) {
|
||||||
|
try { sha = await resolveSha(repo, branch, null) } catch (err) {
|
||||||
|
console.error(`[previewDeploy] Dev-Registrierung ${sub} uebersprungen:`, err.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let env = {}
|
||||||
|
if (project.previewEnv && key) {
|
||||||
|
try { env = decryptEnv(project.previewEnv, key) } catch { env = {} }
|
||||||
|
}
|
||||||
|
await runnerClient.startDev(sub, { sub, repo, sha, branch, mode: 'dev', env })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[previewDeploy] Dev-Registrierung ${sub} fehlgeschlagen:`, err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})().finally(() => { registering = null })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nach einem Server-Neustart startet der Kundenbereich oft vor Appwrite. Dann
|
* Nach einem Server-Neustart startet der Kundenbereich oft vor Appwrite. Dann
|
||||||
* kennt syncPreviewTls die oeffentlichen Seiten nur aus der letzten Datei; der
|
* kennt syncPreviewTls die oeffentlichen Seiten nur aus der letzten Datei; der
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { test, before, after, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import http from 'node:http'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||||
|
|
||||||
|
let aw, runner, gitea, tmp, pd, config, hostsFile, envLib
|
||||||
|
const key = crypto.randomBytes(32)
|
||||||
|
const BUILT = 'c'.repeat(40)
|
||||||
|
const HEAD = 'd'.repeat(40)
|
||||||
|
const puts = []
|
||||||
|
let giteaDown = false
|
||||||
|
let runnerFails = false
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
aw = await startFakeAppwrite({ seed: { websiteProjects: [] } })
|
||||||
|
runner = http.createServer((req, res) => {
|
||||||
|
let raw = ''
|
||||||
|
req.on('data', (c) => (raw += c))
|
||||||
|
req.on('end', () => {
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
if (req.method === 'PUT' && req.url.startsWith('/dev/')) {
|
||||||
|
puts.push({ url: req.url, body: JSON.parse(raw) })
|
||||||
|
res.statusCode = runnerFails ? 500 : 200
|
||||||
|
return res.end(JSON.stringify(runnerFails ? { error: 'kaputt' } : { state: 'sleeping' }))
|
||||||
|
}
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end('{}')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await new Promise((r) => runner.listen(0, '127.0.0.1', r))
|
||||||
|
gitea = http.createServer((req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
if (giteaDown) { res.statusCode = 500; return res.end('{}') }
|
||||||
|
res.end(JSON.stringify({ commit: { id: HEAD } }))
|
||||||
|
})
|
||||||
|
await new Promise((r) => gitea.listen(0, '127.0.0.1', r))
|
||||||
|
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-dev-'))
|
||||||
|
await fs.mkdir(path.join(tmp, 'previews', 'statisch'), { recursive: true })
|
||||||
|
await fs.mkdir(path.join(tmp, 'previews', 'offen'), { recursive: true })
|
||||||
|
hostsFile = path.join(tmp, 'dynamic', 'preview-hosts.yml')
|
||||||
|
Object.assign(process.env, {
|
||||||
|
APPWRITE_ENDPOINT: aw.endpoint,
|
||||||
|
APPWRITE_API_KEY: 'test-key',
|
||||||
|
PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'),
|
||||||
|
TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'),
|
||||||
|
GITEA_BASE_URL: `http://127.0.0.1:${gitea.address().port}`,
|
||||||
|
GITEA_API_TOKEN: 'tok',
|
||||||
|
PREVIEW_RUNNER_URL: `http://127.0.0.1:${runner.address().port}`,
|
||||||
|
PREVIEW_RUNNER_TOKEN: 'sekret',
|
||||||
|
PREVIEW_ENV_KEY: key.toString('base64'),
|
||||||
|
})
|
||||||
|
;({ config } = await import('../server/config.js'))
|
||||||
|
pd = await import('../server/services/previewDeploy.js')
|
||||||
|
envLib = await import('../server/services/previewEnv.js')
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const base = { previewMode: 'auto' }
|
||||||
|
await createDocument('websiteProjects', { ...base, subdomain: 'offen', isPublic: true, previewState: 'online', previewDetected: 'static' }, 'p1')
|
||||||
|
await createDocument('websiteProjects', { ...base, subdomain: 'statisch', previewState: 'online', previewDetected: 'build:npm' }, 'p2')
|
||||||
|
await createDocument('websiteProjects', {
|
||||||
|
...base, subdomain: 'devapp', repoFullName: 'JUSN/devapp', previewState: 'sleeping', previewDetected: 'dev:npm',
|
||||||
|
previewBuiltSha: BUILT, previewEnv: envLib.encryptEnv({ API_KEY: 'geheim-wert' }, key),
|
||||||
|
}, 'p3')
|
||||||
|
await createDocument('websiteProjects', {
|
||||||
|
...base, subdomain: 'devoff', repoFullName: 'JUSN/devoff', previewState: 'failed', previewDetected: 'dev:npm',
|
||||||
|
}, 'p4')
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await new Promise((r) => runner.close(r))
|
||||||
|
await new Promise((r) => gitea.close(r))
|
||||||
|
await aw.close()
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
puts.length = 0
|
||||||
|
giteaDown = false
|
||||||
|
runnerFails = false
|
||||||
|
config.previewRunner.enabled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const read = () => fs.readFile(hostsFile, 'utf8')
|
||||||
|
|
||||||
|
// Erwartete Datei von VOR Task 10 (Schalter aus): zwei Ordner, 'offen' oeffentlich
|
||||||
|
const block = (name, isPublic) => [
|
||||||
|
` preview-${name}-https:`,
|
||||||
|
` rule: "Host(\`${name}.project.webklar.com\`)"`,
|
||||||
|
' entryPoints:',
|
||||||
|
' - websecure',
|
||||||
|
' service: preview-nginx@docker',
|
||||||
|
...(isPublic ? [] : [' middlewares:', ' - preview-chain@docker']),
|
||||||
|
' priority: 100',
|
||||||
|
' tls:',
|
||||||
|
' certResolver: letsencrypt',
|
||||||
|
'',
|
||||||
|
` preview-${name}-http:`,
|
||||||
|
` rule: "Host(\`${name}.project.webklar.com\`)"`,
|
||||||
|
' entryPoints:',
|
||||||
|
' - web',
|
||||||
|
' service: preview-nginx@docker',
|
||||||
|
' middlewares:',
|
||||||
|
' - preview-sites-redirect@docker',
|
||||||
|
' priority: 100',
|
||||||
|
].join('\n')
|
||||||
|
const LEGACY = [
|
||||||
|
'# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually',
|
||||||
|
'http:',
|
||||||
|
' routers:',
|
||||||
|
block('offen', true),
|
||||||
|
block('statisch', false),
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const routerBlock = (content, name) => {
|
||||||
|
const start = content.indexOf(` preview-${name}-https:`)
|
||||||
|
assert.ok(start >= 0, `Router ${name} fehlt`)
|
||||||
|
const rest = content.slice(start + 1)
|
||||||
|
const next = rest.search(/\n {4}preview-[a-z0-9-]+-https:/)
|
||||||
|
return next < 0 ? content.slice(start) : content.slice(start, start + 1 + next)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Schalter aus: Datei byte-identisch wie bisher, keine Dev-Hosts, keine Registrierung', async () => {
|
||||||
|
config.previewRunner.enabled = false
|
||||||
|
const result = await pd.syncPreviewTls()
|
||||||
|
assert.deepEqual(result, { synced: true, hosts: 2, publicSource: 'appwrite' })
|
||||||
|
assert.equal(await read(), LEGACY)
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
assert.equal(puts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Dev-Projekt bekommt Runner-Service (auch ohne Ordner), static bleibt nginx', async () => {
|
||||||
|
await pd.syncPreviewTls()
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
const content = await read()
|
||||||
|
const dev = routerBlock(content, 'devapp')
|
||||||
|
assert.equal((dev.match(/service: preview-runner@docker/g) || []).length, 2) // HTTPS + HTTP
|
||||||
|
assert.match(dev, /preview-chain@docker/) // nicht oeffentlich => geschuetzt
|
||||||
|
assert.match(dev, /preview-sites-redirect@docker/)
|
||||||
|
assert.match(routerBlock(content, 'statisch'), /preview-nginx@docker/)
|
||||||
|
assert.doesNotMatch(routerBlock(content, 'statisch'), /preview-runner/)
|
||||||
|
assert.doesNotMatch(content, /devoff/) // Zustand failed => kein Dev-Host
|
||||||
|
assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)], ['devapp'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async () => {
|
||||||
|
await pd.syncPreviewTls()
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getPublicSubdomains
|
||||||
|
aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getDevProjects
|
||||||
|
const errors = console.error
|
||||||
|
console.error = () => {}
|
||||||
|
try {
|
||||||
|
const result = await pd.syncPreviewTls()
|
||||||
|
assert.equal(result.publicSource, 'previous-file')
|
||||||
|
} finally { console.error = errors }
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())], ['devapp'])
|
||||||
|
assert.match(routerBlock(await read(), 'devapp'), /preview-runner@docker/)
|
||||||
|
assert.equal(puts.length, 1) // nur die Runde mit Appwrite-Daten
|
||||||
|
})
|
||||||
|
|
||||||
|
test('devSubdomainsFromHostsFile: nur HTTPS-Router mit Runner-Service', () => {
|
||||||
|
const content = [
|
||||||
|
'http:', ' routers:',
|
||||||
|
' preview-a-https:', ' service: preview-runner@docker',
|
||||||
|
' preview-a-http:', ' service: preview-runner@docker',
|
||||||
|
' preview-b-https:', ' service: preview-nginx@docker',
|
||||||
|
' preview-c-d-https:', ' rule: x', ' service: preview-runner@docker',
|
||||||
|
].join('\n')
|
||||||
|
assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['a', 'c-d'])
|
||||||
|
assert.equal(pd.devSubdomainsFromHostsFile('').size, 0)
|
||||||
|
assert.equal(pd.devSubdomainsFromHostsFile(undefined).size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Registrierung: Runner bekommt Spec mit entschluesselten Variablen', async () => {
|
||||||
|
await pd.syncPreviewTls()
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
assert.equal(puts.length, 1)
|
||||||
|
assert.equal(puts[0].url, '/dev/devapp')
|
||||||
|
assert.deepEqual(puts[0].body, {
|
||||||
|
sub: 'devapp', repo: 'JUSN/devapp', sha: BUILT, branch: 'main', mode: 'dev', env: { API_KEY: 'geheim-wert' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Registrierung: ohne PREVIEW_ENV_KEY env = {}', async () => {
|
||||||
|
const saved = process.env.PREVIEW_ENV_KEY
|
||||||
|
delete process.env.PREVIEW_ENV_KEY
|
||||||
|
try {
|
||||||
|
await pd.syncPreviewTls()
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
} finally { process.env.PREVIEW_ENV_KEY = saved }
|
||||||
|
assert.deepEqual(puts[0].body.env, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Registrierung: Runner-Fehler bricht den Abgleich nicht, Datei ist geschrieben', async () => {
|
||||||
|
runnerFails = true
|
||||||
|
await fs.rm(hostsFile, { force: true })
|
||||||
|
const errors = console.error
|
||||||
|
console.error = () => {}
|
||||||
|
try {
|
||||||
|
const result = await pd.syncPreviewTls()
|
||||||
|
assert.equal(result.synced, true)
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
} finally { console.error = errors }
|
||||||
|
assert.equal(puts.length, 1)
|
||||||
|
assert.match(await read(), /preview-runner@docker/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Registrierung: ohne gebauten Stand wird der Branch-Kopf genutzt, bei Fehlschlag uebersprungen', async () => {
|
||||||
|
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
await updateDocument('websiteProjects', 'p3', { previewBuiltSha: '' })
|
||||||
|
try {
|
||||||
|
await pd.syncPreviewTls()
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
assert.equal(puts[0].body.sha, HEAD)
|
||||||
|
puts.length = 0
|
||||||
|
giteaDown = true
|
||||||
|
const errors = console.error
|
||||||
|
console.error = () => {}
|
||||||
|
try {
|
||||||
|
await pd.syncPreviewTls()
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
} finally { console.error = errors }
|
||||||
|
assert.equal(puts.length, 0)
|
||||||
|
assert.match(await read(), /preview-runner@docker/) // Router trotzdem da
|
||||||
|
} finally {
|
||||||
|
await updateDocument('websiteProjects', 'p3', { previewBuiltSha: BUILT })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Registrierung: keine zwei Runden gleichzeitig', async () => {
|
||||||
|
await Promise.all([pd.syncPreviewTls(), pd.syncPreviewTls()])
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
assert.equal(puts.length, 1)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user