[verified] refactor: establish service and frontend boundaries
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, writeFile, access } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileConfigStore } from '../server/config/file-config-store.js'
|
||||
import { ClientRegistry } from '../server/clients/client-registry.js'
|
||||
import { buildApp } from '../server/app.js'
|
||||
import { safeProxyPath } from '../server/proxy/service.js'
|
||||
import { RequestCoordinator } from '../public/state/request-coordinator.js'
|
||||
import { normalizeTargetUrl } from '../public/infrastructure/api-client.js'
|
||||
|
||||
async function fixtureConfig(instances = []) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'multi-simadmin-phase1-'))
|
||||
const configPath = path.join(dir, 'config.json')
|
||||
const examplePath = path.join(dir, 'config.example.json')
|
||||
await writeFile(examplePath, JSON.stringify({ server: { port: 8788 }, instances }))
|
||||
return { dir, configPath, examplePath }
|
||||
}
|
||||
|
||||
function fakeClient(instance, calls = []) {
|
||||
return {
|
||||
instance,
|
||||
jar: { clear() {} },
|
||||
ensureAuthenticated: async () => ({ authenticated: true }),
|
||||
fetchJson: async () => ({ ok: true, status: 200, latencyMs: 1, data: {} }),
|
||||
request: async (url, options) => {
|
||||
calls.push({ url, options })
|
||||
return new Response('{}', { headers: { 'content-type': 'application/json', 'set-cookie': 'secret=x' } })
|
||||
},
|
||||
close() {},
|
||||
}
|
||||
}
|
||||
|
||||
test('FileConfigStore uses example only as template and commits immutable snapshots atomically', async () => {
|
||||
const { configPath, examplePath } = await fixtureConfig()
|
||||
const before = await readFile(examplePath, 'utf8')
|
||||
const store = await FileConfigStore.open({ configPath, examplePath })
|
||||
assert.equal(store.snapshot.server.host, '127.0.0.1')
|
||||
assert.ok(Object.isFrozen(store.snapshot.instances))
|
||||
await store.add({ id: 'one', url: 'http://127.0.0.1:3000' })
|
||||
assert.equal(JSON.parse(await readFile(configPath, 'utf8')).instances[0].id, 'one')
|
||||
assert.equal(await readFile(examplePath, 'utf8'), before)
|
||||
})
|
||||
|
||||
test('failed persistence does not publish memory and transactions serialize', async () => {
|
||||
const { configPath, examplePath } = await fixtureConfig()
|
||||
let writes = 0
|
||||
const store = await FileConfigStore.open({ configPath, examplePath, atomicWrite: async () => { writes += 1; throw new Error('disk full') } })
|
||||
await assert.rejects(store.add({ id: 'x', url: 'http://127.0.0.1' }), /disk full/)
|
||||
assert.equal(store.snapshot.instances.length, 0)
|
||||
assert.equal(writes, 1)
|
||||
await assert.rejects(access(configPath))
|
||||
})
|
||||
|
||||
test('ClientRegistry reconciles only committed config and handles rename/delete', () => {
|
||||
const closed = []
|
||||
const registry = new ClientRegistry({ createClient: i => ({ instance: i, close: () => closed.push(i.id) }) })
|
||||
registry.reconcile([{ id: 'old', url: 'http://a' }])
|
||||
registry.reconcile([{ id: 'new', url: 'http://a' }])
|
||||
assert.equal(registry.get('old'), undefined)
|
||||
assert.equal(registry.get('new').instance.id, 'new')
|
||||
assert.deepEqual(closed, ['old'])
|
||||
registry.close()
|
||||
assert.deepEqual(closed, ['old', 'new'])
|
||||
})
|
||||
|
||||
test('proxy path and target URL contracts reject origin escape vectors', () => {
|
||||
assert.equal(safeProxyPath('api/device'), '/api/device')
|
||||
for (const value of ['../evil', 'api\\evil', '%2f%2fevil', '%5cevil', 'api/%2e%2e/x']) {
|
||||
assert.throws(() => safeProxyPath(value), /invalid proxy path/)
|
||||
}
|
||||
assert.equal(normalizeTargetUrl('https://example.test/'), 'https://example.test')
|
||||
assert.throws(() => normalizeTargetUrl('file:///tmp/x'), /http or https/)
|
||||
})
|
||||
|
||||
test('buildApp supports inject without listening and covers health/readiness/config/auth/proxy/404', async t => {
|
||||
const { configPath, examplePath } = await fixtureConfig([{ id: 'one', url: 'http://127.0.0.1:3000' }])
|
||||
const store = await FileConfigStore.open({ configPath, examplePath })
|
||||
const calls = []
|
||||
const registry = new ClientRegistry({ createClient: instance => fakeClient(instance, calls) }).reconcile(store.snapshot.instances)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false })
|
||||
t.after(() => app.close())
|
||||
assert.deepEqual((await app.inject('/api/health')).json(), { ok: true })
|
||||
assert.deepEqual((await app.inject('/api/ready')).json(), { ready: true })
|
||||
assert.equal((await app.inject('/api/config')).statusCode, 200)
|
||||
const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'two', url: 'https://example.test' } })
|
||||
assert.equal(created.statusCode, 200)
|
||||
const renamed = await app.inject({ method: 'PUT', url: '/api/instances/two', payload: { id: 'renamed' } })
|
||||
assert.equal(renamed.json().instance.id, 'renamed')
|
||||
assert.equal(registry.get('two'), undefined)
|
||||
assert.ok(registry.get('renamed'))
|
||||
const removed = await app.inject({ method: 'DELETE', url: '/api/instances/renamed' })
|
||||
assert.equal(removed.json().removed.id, 'renamed')
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/instances/one/login', payload: {} })).statusCode, 200)
|
||||
assert.deepEqual((await app.inject({ method: 'POST', url: '/api/instances/one/logout' })).json(), { ok: true })
|
||||
assert.equal((await app.inject('/api/status/one')).statusCode, 200)
|
||||
const proxy = await app.inject({ method: 'POST', url: '/api/proxy/one/api/test', headers: { cookie: 'browser=x', authorization: 'Bearer x', forwarded: 'bad', 'content-type': 'application/json' }, payload: { hello: 'world' } })
|
||||
assert.equal(proxy.statusCode, 200)
|
||||
assert.equal(proxy.headers['set-cookie'], undefined)
|
||||
assert.deepEqual(proxy.json(), {})
|
||||
assert.equal(calls[0].options.headers.has('cookie'), false)
|
||||
assert.equal(calls[0].options.headers.has('authorization'), false)
|
||||
assert.equal(calls[0].options.headers.has('forwarded'), false)
|
||||
assert.equal(calls[0].options.body, JSON.stringify({ hello: 'world' }))
|
||||
assert.equal(new URL(calls[0].url).origin, 'http://127.0.0.1:3000')
|
||||
assert.deepEqual((await app.inject('/api/missing')).json(), { error: 'not found' })
|
||||
})
|
||||
|
||||
test('request coordinator is latest-wins per owner and binds result to request owner', async () => {
|
||||
const coordinator = new RequestCoordinator()
|
||||
let finishFirst
|
||||
const first = coordinator.run('status', 'device-a', () => new Promise(resolve => { finishFirst = resolve }))
|
||||
const second = coordinator.run('status', 'device-b', async () => 'new')
|
||||
assert.deepEqual(await second, { accepted: true, ownerId: 'device-b', value: 'new' })
|
||||
finishFirst('old')
|
||||
assert.deepEqual(await first, { accepted: false, ownerId: 'device-a', value: 'old' })
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { FileConfigStore, atomicWriteJson } from '../server/config/file-config-store.js'
|
||||
import { normalizeConfig } from '../server/config/schema.js'
|
||||
import { ClientRegistry } from '../server/clients/client-registry.js'
|
||||
import { safeProxyPath, proxyHeaders } from '../server/proxy/service.js'
|
||||
import { createSimAdminClient } from '../server/core.js'
|
||||
import { collectInstanceStatus } from '../server/status/service.js'
|
||||
import { buildApp } from '../server/app.js'
|
||||
import { RequestCoordinator } from '../public/state/request-coordinator.js'
|
||||
import { createApiClient } from '../public/infrastructure/api-client.js'
|
||||
import { createViewRouter } from '../public/router/view-router.js'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
|
||||
|
||||
async function files(instances = []) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'msa-blockers-'))
|
||||
const configPath = path.join(dir, 'config.json')
|
||||
const examplePath = path.join(dir, 'example.json')
|
||||
await writeFile(examplePath, JSON.stringify({ server: { port: 8788 }, instances }))
|
||||
return { dir, configPath, examplePath }
|
||||
}
|
||||
|
||||
function client(instance, request) {
|
||||
return { instance, request, jar: { clear() {} }, fetchJson: async () => ({ ok: true, status: 200, latencyMs: 1, data: {} }), ensureAuthenticated: async () => ({ authenticated: true }) }
|
||||
}
|
||||
|
||||
test('proxy path rejects normalization and repeated-decoding structure changes', () => {
|
||||
assert.equal(safeProxyPath('api/a%20b'), '/api/a%20b')
|
||||
for (const input of ['%252e%252e/x', 'api/%252f/x', 'api/%255c/x', 'api/a%2525252fetc', 'api/%2525252e%2525252e/x', 'api/%2525253fx', 'api\\x', '//evil/x', '%2f%2fevil/x', 'api/%3fadmin', 'api/%253fadmin', 'api/%23x', 'api/%2523x', 'api/%2e%2e/x']) {
|
||||
assert.throws(() => safeProxyPath(input), /invalid proxy path/, input)
|
||||
}
|
||||
assert.equal(proxyHeaders({ 'accept-encoding': 'gzip', accept: 'json' }).has('accept-encoding'), false)
|
||||
})
|
||||
|
||||
test('FileConfigStore preserves constructor env across transactions and serializes real writes', async () => {
|
||||
const { configPath, examplePath } = await files()
|
||||
const store = await FileConfigStore.open({ configPath, examplePath, env: { HOST: 'env-host', PORT: '4321' } })
|
||||
await Promise.all([
|
||||
store.add({ id: 'a', url: 'http://a.test' }),
|
||||
store.add({ id: 'b', url: 'http://b.test' }),
|
||||
])
|
||||
assert.equal(store.snapshot.server.host, 'env-host')
|
||||
assert.equal(store.snapshot.server.port, 4321)
|
||||
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances.map(x => x.id), ['a', 'b'])
|
||||
})
|
||||
|
||||
test('atomicWriteJson replaces content, leaves no temp and creates POSIX 0600', async () => {
|
||||
const { dir, configPath } = await files()
|
||||
await writeFile(configPath, 'old', { mode: 0o644 })
|
||||
await atomicWriteJson(configPath, { ok: true })
|
||||
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')), { ok: true })
|
||||
assert.deepEqual((await readdir(dir)).filter(x => x.endsWith('.tmp')), [])
|
||||
if (process.platform !== 'win32') assert.equal((await stat(configPath)).mode & 0o777, 0o600)
|
||||
})
|
||||
|
||||
test('schema rejects invalid port and auth mode', () => {
|
||||
for (const port of [0, 65536, 1.5, 'wat']) assert.throws(() => normalizeConfig({ server: { port }, instances: [] }, {}), /port/)
|
||||
assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url: 'http://x.test', auth: { mode: 'token' } }] }, {}), /auth.mode/)
|
||||
})
|
||||
|
||||
test('registry reconcile builds next map before publishing and closes staged clients on create failure', () => {
|
||||
const old = { id: 'old', url: 'http://old.test', auth: { mode: 'none', password: '' } }
|
||||
let stagedClosed = false
|
||||
const registry = new ClientRegistry({ createClient: item => {
|
||||
if (item.id === 'bad') throw new Error('boom')
|
||||
return { instance: item, close() { if (item.id === 'staged') stagedClosed = true } }
|
||||
} }).reconcile([old])
|
||||
assert.throws(() => registry.reconcile([{ id: 'staged', url: 'http://staged.test' }, { id: 'bad', url: 'http://bad.test' }]), /boom/)
|
||||
assert.equal(stagedClosed, true)
|
||||
assert.equal(registry.get('old').instance.id, 'old')
|
||||
assert.equal(registry.get('bad'), undefined)
|
||||
})
|
||||
|
||||
test('temporary login credential is ephemeral, saved flag comes from store, logout clears it', async t => {
|
||||
const { configPath, examplePath } = await files([{ id: 'one', url: 'http://sim.test' }])
|
||||
const store = await FileConfigStore.open({ configPath, examplePath })
|
||||
let credential
|
||||
let cleared = false
|
||||
const fake = { instance: structuredClone(store.snapshot.instances[0]), jar: { clear() {} }, ensureAuthenticated: async options => { credential = options?.credential; return { authenticated: true } }, clearEphemeralSecret: () => { cleared = true }, fetchJson: async () => ({ ok: true }) }
|
||||
const registry = new ClientRegistry({ createClient: () => fake }).reconcile(store.snapshot.instances)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
const login = await app.inject({ method: 'POST', url: '/api/instances/one/login', payload: { password: 'temporary' } })
|
||||
assert.equal(credential, 'temporary')
|
||||
assert.equal(fake.instance.auth.password, '')
|
||||
assert.equal(login.json().hasSavedPassword, false)
|
||||
await app.inject({ method: 'POST', url: '/api/instances/one/logout' })
|
||||
assert.equal(cleared, true)
|
||||
})
|
||||
|
||||
test('failed temporary login credential is not retained for later automatic attempts', async () => {
|
||||
const instance = { id: 'x', url: 'http://x.test', auth: { mode: 'none', password: '' } }
|
||||
const bodies = []
|
||||
const c = createSimAdminClient(instance, { fetchImpl: async (url, options = {}) => {
|
||||
const pathname = new URL(url).pathname
|
||||
if (pathname === '/api/auth/status') return new Response(JSON.stringify({ configured: true, authenticated: false, settings: { password_protection_enabled: true } }), { status: 200 })
|
||||
bodies.push(options.body)
|
||||
return new Response('{}', { status: 401 })
|
||||
} })
|
||||
const failed = await c.ensureAuthenticated({ credential: 'wrong' })
|
||||
assert.equal(failed.authenticated, false)
|
||||
const later = await c.ensureAuthenticated()
|
||||
assert.equal(later.loginAttempted, false)
|
||||
assert.equal(later.reason, 'password_required')
|
||||
assert.deepEqual(bodies, ['{"password":"wrong"}'])
|
||||
})
|
||||
|
||||
test('proxy body/header contract handles JSON text buffer, gzip metadata and stable 415', async t => {
|
||||
const { configPath, examplePath } = await files([{ id: 'one', url: 'http://sim.test/base' }])
|
||||
const store = await FileConfigStore.open({ configPath, examplePath })
|
||||
const calls = []
|
||||
const registry = new ClientRegistry({ createClient: instance => client(instance, async (url, options) => {
|
||||
calls.push({ url: String(url), options })
|
||||
return new Response('decoded', { status: 503, headers: { 'content-encoding': 'gzip', 'content-length': '999', 'x-upstream': 'yes' } })
|
||||
}) }).reconcile(store.snapshot.instances)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
const json = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?q=1', headers: { 'content-type': 'application/json', 'accept-encoding': 'gzip' }, payload: { x: 1 } })
|
||||
assert.equal(json.statusCode, 503); assert.equal(calls[0].options.body, '{"x":1}'); assert.equal(new URL(calls[0].url).pathname, '/api/x'); assert.equal(new URL(calls[0].url).search, '?q=1')
|
||||
assert.equal(calls[0].options.headers.has('accept-encoding'), false); assert.equal(json.headers['content-encoding'], undefined); assert.equal(json.headers['content-length'], '7')
|
||||
await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'text/plain' }, payload: 'hello' }); assert.equal(calls[1].options.body, 'hello')
|
||||
await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'application/octet-stream' }, payload: Buffer.from('bin') }); assert.ok(Buffer.isBuffer(calls[2].options.body))
|
||||
const empty = await app.inject({ method: 'POST', url: '/api/proxy/one/api/logout' }); assert.equal(empty.statusCode, 503); assert.equal(calls[3].options.body, undefined)
|
||||
const unsupported = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'multipart/form-data; boundary=x' }, payload: '--x--' }); assert.equal(unsupported.statusCode, 415); assert.deepEqual(unsupported.json(), { error: 'unsupported proxy content type' })
|
||||
})
|
||||
|
||||
test('committed config survives reconcile failure and readiness degrades then retry recovers', async t => {
|
||||
const { configPath, examplePath } = await files()
|
||||
const store = await FileConfigStore.open({ configPath, examplePath })
|
||||
let fail = true
|
||||
const registry = new ClientRegistry({ createClient: instance => { if (fail) throw new Error('factory'); return client(instance, async () => new Response('{}')) } })
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, reconcileRetryMs: 10 }); t.after(() => app.close())
|
||||
const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'x', url: 'http://x.test' } })
|
||||
assert.equal(created.statusCode, 200); assert.equal(created.json().reconcilePending, true); assert.equal((await app.inject('/api/ready')).json().ready, false)
|
||||
fail = false
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
assert.equal((await app.inject('/api/ready')).json().ready, true); assert.ok(registry.get('x'))
|
||||
})
|
||||
|
||||
test('status supports registration_status and health 404 with successful business endpoint', async () => {
|
||||
const values = new Map([['/api/health', { ok: false, status: 404, latencyMs: 2, data: null }], ['/api/auth/status', { ok: false, status: 404, data: null }], ['/api/device', { ok: true, status: 200, data: { model: 'X' } }], ['/api/network', { ok: true, status: 200, data: { registration_status: 'registered' } }]])
|
||||
const instance = { id: 'x', name: 'x', url: 'http://x.test', auth: { mode: 'none', password: '' } }
|
||||
const c = createSimAdminClient(instance, { fetchImpl: async url => { const v = values.get(new URL(url).pathname) || { ok: false, status: 404, data: null }; return new Response(JSON.stringify(v.data), { status: v.status }) } })
|
||||
const status = await collectInstanceStatus(c)
|
||||
assert.equal(status.reachable, true); assert.notEqual(status.authenticated, false); assert.equal(status.summary.network.registration, 'registered')
|
||||
})
|
||||
|
||||
test('bootstrap copies immutable listen options before handing them to Fastify', async () => {
|
||||
const source = await readFile(path.join(projectRoot, 'server/index.js'), 'utf8')
|
||||
assert.match(source, /app\.listen\(\{\s*\.\.\.configStore\.snapshot\.server\s*\}\)/)
|
||||
assert.doesNotMatch(source, /app\.listen\(configStore\.snapshot\.server\)/)
|
||||
})
|
||||
|
||||
test('coordinator structures rejection and stale errors; api client/router modules are usable', async () => {
|
||||
const coordinator = new RequestCoordinator(); let rejectOld
|
||||
const old = coordinator.run('login', 'a', () => new Promise((_, reject) => { rejectOld = reject }))
|
||||
assert.deepEqual(await coordinator.run('login', 'b', async () => 'ok'), { accepted: true, ownerId: 'b', value: 'ok' })
|
||||
rejectOld(new Error('old')); const stale = await old
|
||||
assert.equal(stale.accepted, false); assert.equal(stale.ownerId, 'a'); assert.match(stale.error.message, /old/)
|
||||
const api = createApiClient({ fetchImpl: async () => new Response('{"ok":true}') }); assert.deepEqual(await api.config(), { ok: true })
|
||||
const classes = () => ({ active: false, toggle(_name, value) { this.active = value } }); const control = { dataset: { view: 'home' }, classList: classes() }; const panel = { id: 'homeView', classList: classes() }
|
||||
createViewRouter({ controls: () => [control], panels: () => [panel], storage: null })('home'); assert.equal(control.classList.active, true); assert.equal(panel.classList.active, true)
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance, loadConfig, addInstanceToConfig, updateInstanceInConfig, deleteInstanceFromConfig } from '../server/core.js'
|
||||
import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance } from '../server/core.js'
|
||||
|
||||
test('normalizeInstance supports passwordless and password-protected instances without exposing password', () => {
|
||||
const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://127.0.0.1:3000/' }, 0)
|
||||
@@ -66,6 +63,7 @@ test('snapshot summary exposes device-monitoring fields from stats, sms, data an
|
||||
assert.equal(snapshot.sim.present, true)
|
||||
assert.equal(snapshot.sim.phoneNumber, '13800138000')
|
||||
assert.equal(snapshot.network.operator, '中国移动')
|
||||
assert.equal(snapshot.network.registration, 'registered')
|
||||
assert.equal(snapshot.network.signal, -73)
|
||||
assert.equal(snapshot.network.accessTechnology, 'LTE')
|
||||
assert.equal(snapshot.sms.total, 12)
|
||||
@@ -83,28 +81,3 @@ test('snapshot summary exposes device-monitoring fields from stats, sms, data an
|
||||
assert.equal(snapshot.system.info.os, 'OpenWrt')
|
||||
assert.equal(snapshot.system.uptime, 3600)
|
||||
})
|
||||
|
||||
test('config store can add update and delete instances while persisting json', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'multi-simadmin-'))
|
||||
const configPath = path.join(dir, 'config.json')
|
||||
await writeFile(configPath, JSON.stringify({ server: { host: '127.0.0.1', port: 9999 }, instances: [] }, null, 2))
|
||||
const config = await loadConfig({ configPath, defaultConfigPath: configPath })
|
||||
|
||||
const added = await addInstanceToConfig(config, { id: 'home-cpe', name: '家里 CPE', url: 'http://192.168.1.1/', auth: { password: 'pw' }, tags: ['home'] })
|
||||
assert.equal(added.id, 'home-cpe')
|
||||
assert.equal(added.url, 'http://192.168.1.1')
|
||||
assert.equal(config.instances.length, 1)
|
||||
|
||||
const updated = await updateInstanceInConfig(config, 'home-cpe', { name: '客厅 CPE', url: 'http://192.168.1.2', auth: { mode: 'none' }, description: 'main router' })
|
||||
assert.equal(updated.name, '客厅 CPE')
|
||||
assert.equal(updated.auth.password, '')
|
||||
|
||||
const persisted = JSON.parse(await readFile(configPath, 'utf8'))
|
||||
assert.equal(persisted.instances[0].name, '客厅 CPE')
|
||||
assert.equal(persisted.instances[0].url, 'http://192.168.1.2')
|
||||
|
||||
const removed = await deleteInstanceFromConfig(config, 'home-cpe')
|
||||
assert.equal(removed.id, 'home-cpe')
|
||||
assert.equal(config.instances.length, 0)
|
||||
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances, [])
|
||||
})
|
||||
|
||||
@@ -24,7 +24,8 @@ test('home screen is a simadmin card wall and device detail owns interactions',
|
||||
assert.match(app, /function openDeviceDetail/)
|
||||
assert.match(app, /function renderDeviceDetails/)
|
||||
assert.match(app, /data-open-detail/)
|
||||
assert.match(app, /\['home', 'detail', 'rawpage', 'api'\]/)
|
||||
const router = await text('public/router/view-router.js')
|
||||
assert.match(router, /\['home', 'detail', 'rawpage', 'api'\]/)
|
||||
assert.match(app, /温度/)
|
||||
assert.match(app, /流量|速率/)
|
||||
assert.match(app, /内存|磁盘/)
|
||||
@@ -37,7 +38,13 @@ test('home screen is a simadmin card wall and device detail owns interactions',
|
||||
})
|
||||
|
||||
test('home device cards show complete content without ellipsis truncation', async () => {
|
||||
const css = await text('public/styles.css')
|
||||
const entry = await text('public/styles.css')
|
||||
const css = await text('public/styles/components.css')
|
||||
|
||||
assert.match(entry, /styles\/tokens\.css/)
|
||||
assert.match(entry, /styles\/base\.css/)
|
||||
assert.match(entry, /styles\/components\.css/)
|
||||
assert.match(entry, /styles\/responsive\.css/)
|
||||
|
||||
assert.match(css, /\.sim-device-card/)
|
||||
assert.match(css, /\.home-device-grid/)
|
||||
|
||||
Reference in New Issue
Block a user