feat: add device sharing metadata application
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createServer as createHttpServer } from 'node:http'
|
||||
import { build } from 'esbuild'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it.skipIf(!process.env.GOODBUDDY_SHARING_SERVER_ROOT)('uses the real ShareServer through Electron preload and trusted Main IPC', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-sharing-electron-'))
|
||||
const portProbe = createHttpServer()
|
||||
await new Promise<void>(resolve => portProbe.listen(0, '127.0.0.1', resolve))
|
||||
const port = (portProbe.address() as { port: number }).port
|
||||
await new Promise<void>(resolve => portProbe.close(() => resolve()))
|
||||
const backend = spawn(process.execPath, ['--import', 'tsx', 'server/index.ts'], {
|
||||
cwd: process.env.GOODBUDDY_SHARING_SERVER_ROOT,
|
||||
env: { ...process.env, SHARESERVER_PORT: String(port), SHARESERVER_DATA_DIR: join(directory, 'server-data') },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let backendOutput = ''
|
||||
backend.stdout.on('data', data => { backendOutput += data })
|
||||
backend.stderr.on('data', data => { backendOutput += data })
|
||||
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [react(), { name: 'sharing-smoke', configureServer(server) {
|
||||
server.middlewares.use('/sharing.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(await server.transformIndexHtml('/sharing.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/device-sharing-renderer.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(backendOutput || 'ShareServer startup timed out')), 15000)
|
||||
backend.once('error', reject)
|
||||
backend.once('exit', code => { clearTimeout(timer); reject(new Error(`ShareServer exit ${code}: ${backendOutput}`)) })
|
||||
backend.stdout.on('data', () => { if (backendOutput.includes('listening on')) { clearTimeout(timer); resolve() } })
|
||||
})
|
||||
await build({ entryPoints: ['tests/support/device-sharing-main.ts'], outfile: join(directory, 'main.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||
await server.listen()
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GB_SHARING_DIRECTORY: directory, GB_SHARING_URL: `http://127.0.0.1:${port}`,
|
||||
GB_SHARING_RENDERER: server.resolvedUrls!.local[0] + 'sharing.html' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [join(directory, 'main.cjs')], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
const timer = setTimeout(() => child.kill(), 60000)
|
||||
try {
|
||||
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
|
||||
process.stdout.write(output)
|
||||
} finally { clearTimeout(timer) }
|
||||
} finally {
|
||||
const stopped = new Promise(resolve => backend.once('exit', resolve))
|
||||
if (backend.exitCode === null) { backend.kill(); await stopped }
|
||||
await server.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 90000)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import { DeviceSharingService } from '../../src/main/device-sharing-service'
|
||||
import { registerDeviceSharingIpc } from '../../src/main/device-sharing-ipc'
|
||||
|
||||
const directory = process.env.GB_SHARING_DIRECTORY!
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
void app.whenReady().then(async () => {
|
||||
const file = join(directory, 'sharing-settings.json')
|
||||
const service = new DeviceSharingService(file, 'electron-smoke')
|
||||
await service.saveSettings({ name: 'Sharing smoke device', serverUrl: process.env.GB_SHARING_URL! })
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 850, webPreferences: {
|
||||
preload: join(directory, 'preload.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false
|
||||
} })
|
||||
const dispose = registerDeviceSharingIpc(win, service)
|
||||
const errors: string[] = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
const js = (code: string) => win.webContents.executeJavaScript(code)
|
||||
const wait = async (code: string): Promise<void> => {
|
||||
for (let count = 0; count < 300; count++) { if (await js(code)) return; await setTimeout(25) }
|
||||
throw new Error(`Timed out: ${code}; ${errors.join('\n')}`)
|
||||
}
|
||||
const click = async (text: string): Promise<void> => {
|
||||
await wait(`[...document.querySelectorAll('button')].some(e => e.textContent === ${JSON.stringify(text)} && !e.disabled)`)
|
||||
const rect = await js(`(() => { const e = [...document.querySelectorAll('button')].find(e=>e.textContent===${JSON.stringify(text)}); e.scrollIntoView({block:'center'}); const r=e.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)} })()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', ...rect, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', ...rect, button: 'left', clickCount: 1 })
|
||||
}
|
||||
const field = async (label: string, value: string, tag = 'input'): Promise<void> => {
|
||||
await js(`(() => { const e = [...document.querySelectorAll('label')].find(e => e.querySelector('span')?.textContent === ${JSON.stringify(label)}).querySelector(${JSON.stringify(tag)}); Object.getOwnPropertyDescriptor(${tag === 'select' ? 'HTMLSelectElement' : 'HTMLInputElement'}.prototype,'value').set.call(e,${JSON.stringify(value)}); e.dispatchEvent(new Event('${tag === 'select' ? 'change' : 'input'}',{bubbles:true})); })()`)
|
||||
}
|
||||
try {
|
||||
await win.loadURL(process.env.GB_SHARING_RENDERER!)
|
||||
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
|
||||
await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').click()`)
|
||||
await click('Register this device')
|
||||
await wait(`!!document.querySelector('#sharing-devices + article') || document.querySelector('[aria-labelledby="sharing-devices"]').textContent.includes('Sharing smoke device')`)
|
||||
await field('Entry name', 'Smoke knowledge')
|
||||
await field('Kind', 'knowledge', 'select')
|
||||
await field('Source mode', 'server', 'select')
|
||||
await wait(`document.querySelectorAll('input[type=checkbox]').length === 3`)
|
||||
await js(`document.querySelectorAll('input[type=checkbox]')[0].click(); document.querySelectorAll('input[type=checkbox]')[2].click()`)
|
||||
await click('Publish metadata')
|
||||
await wait(`document.querySelector('[aria-labelledby="sharing-publications"]').textContent.includes('Smoke knowledge')`)
|
||||
let catalog = await service.getCatalog()
|
||||
assert.equal(catalog.devices.length, 1)
|
||||
assert.equal(catalog.publications.length, 1)
|
||||
assert.deepEqual(catalog.publications[0]!.permissions, { search: true, read: false, download: true })
|
||||
assert.equal(catalog.publications[0]!.sourceMode, 'server')
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await js(`document.documentElement.dataset.theme='${theme}'`)
|
||||
win.setContentSize(480, 700)
|
||||
await wait('window.innerWidth === 480')
|
||||
assert.equal(await js('document.documentElement.scrollWidth <= window.innerWidth'), true, 'narrow layout overflow')
|
||||
}
|
||||
await click('Revoke publication')
|
||||
await click('Confirm revocation')
|
||||
await wait(`document.querySelector('[aria-labelledby="sharing-publications"]').textContent.includes('Revoked')`)
|
||||
await click('Refresh lists')
|
||||
await wait(`!document.querySelector('.device-sharing').getAttribute('aria-busy') || document.querySelector('.device-sharing').getAttribute('aria-busy') === 'false'`)
|
||||
catalog = await service.getCatalog()
|
||||
assert.equal(catalog.publications[0]!.status, 'revoked')
|
||||
assert.deepEqual(await new DeviceSharingService(file, 'restart').getSettings(), await service.getSettings())
|
||||
assert.equal(errors.length, 0, errors.join('\n'))
|
||||
console.log('SHARING_ELECTRON_OK: application center -> register -> knowledge/server metadata (search=true, read=false, download=true) -> revoke -> refresh; persisted identity; light/dark 480px; real ShareServer')
|
||||
} finally { dispose(); win.destroy() }
|
||||
}).then(() => app.exit(0), error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ApplicationCenter } from '../../src/renderer/src/ApplicationCenter'
|
||||
import { DeviceSharingPage } from '../../src/renderer/src/DeviceSharingPage'
|
||||
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../src/shared/application-settings-contracts'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
function Harness(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [page, setPage] = useState(false)
|
||||
return <UiLocaleProvider initialPreference="en-US">
|
||||
{open && <ApplicationCenter settings={settings} pending={false} onClose={() => setOpen(false)}
|
||||
onOpen={id => { if (id === 'device-sharing') { setPage(true); setOpen(false) } }}
|
||||
onUpdate={async () => false} onRetry={() => undefined} />}
|
||||
{page && <DeviceSharingPage notify={input => { document.documentElement.dataset.notification = input.message }} />}
|
||||
</UiLocaleProvider>
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<Harness />)
|
||||
Reference in New Issue
Block a user