Files
IOT_SIM/server/test/runner.test.ts
T
xuyueandClaude Opus 5.5 6b088240da
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
项目 4 任务 2 网络摄像机:PictureBox、Image / Bitmap、照片列表
- 摄像头:自绘东围墙场景(Camera/scene.svg),按云台位置切成 640×480 帧(build-frames.ts);
  Pan 每次转一格,红外对射被遮挡时画面出现翻墙的人;类名、委托、事件参数与教材 dll 一致,
  ImageRead 每 250 ms 在后台线程触发
- Image / Bitmap 保存真实图片数据:Save、FromFile、FromStream,文件夹不存在时报 GDI+ 一般性错误;
  图片每张只发给浏览器一次(按内容哈希缓存)
- 程序所在目录(AppDomain.CurrentDomain.BaseDirectory,编译检查单独放行)和当前目录指向可写的 bin/Debug,
  沙箱里在沙箱的临时目录
- PictureBox(SizeMode、BorderStyle);设计器工具箱加"图片框";运行区显示程序保存的图片,点开看大图;
  实训台摄像头图标显示开启状态
- 评分规则加图片框显示图片、摄像头开关与转向、保存图片三种检查;示例 p4-t2-网络摄像机
- 测试:摄像头端到端、图片读写与程序目录、AppDomain 编译检查、评分出错情况

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:58:09 +08:00

645 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材示例工程的行为。
// 运行前先编译运行时:bun run build:runtime
import { afterAll, afterEach, describe, expect, test } from 'bun:test'
import { cp, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared'
import { compileService } from '../src/compile-service'
import { config, ROOT } from '../src/config'
import { cpuLimitMessage, runCommand } from '../src/sandbox'
class Runner {
readonly messages: RunnerMessage[] = []
private proc?: Bun.Subprocess<'pipe', 'pipe', 'inherit'>
private reading?: Promise<void>
private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = []
/** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */
static async start(projectDir: string) {
const runner = new Runner()
const compiled = await compileService.compile(projectDir)
runner.receive({ type: 'compiled', ...compiled })
if (compiled.ok) {
const { cmd, env } = runCommand(projectDir, {})
runner.proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', env })
runner.reading = runner.read()
}
return runner
}
private receive(message: RunnerMessage) {
this.messages.push(message)
this.waiters = this.waiters.filter((w) => {
if (!w.match(message)) return true
w.resolve(message)
return false
})
}
private async read() {
const decoder = new TextDecoder()
let buffer = ''
for await (const chunk of this.proc!.stdout) {
buffer += decoder.decode(chunk, { stream: true })
let index: number
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index)
buffer = buffer.slice(index + 1)
if (!line.trim()) continue
this.receive(JSON.parse(line) as RunnerMessage)
}
}
}
/** 只等待从现在起新收到的消息(不算已经收到的) */
next<T extends RunnerMessage['type']>(
type: T,
match: (m: Extract<RunnerMessage, { type: T }>) => boolean = () => true,
timeoutMs = 15000,
): Promise<Extract<RunnerMessage, { type: T }>> {
const from = this.messages.length
return this.waitFor(type, (m) => this.messages.indexOf(m) >= from && match(m), timeoutMs)
}
/** 等待下一条满足条件的消息(已经收到的也算) */
waitFor<T extends RunnerMessage['type']>(
type: T,
match: (m: Extract<RunnerMessage, { type: T }>) => boolean = () => true,
timeoutMs = 15000,
): Promise<Extract<RunnerMessage, { type: T }>> {
const isMatch = (m: RunnerMessage) => m.type === type && match(m as Extract<RunnerMessage, { type: T }>)
const existing = this.messages.find(isMatch)
if (existing) return Promise.resolve(existing as Extract<RunnerMessage, { type: T }>)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等待 ${type} 超时`)), timeoutMs)
this.waiters.push({
match: isMatch,
resolve: (m) => {
clearTimeout(timer)
resolve(m as Extract<RunnerMessage, { type: T }>)
},
})
})
}
send(message: RunnerInput) {
this.proc!.stdin.write(`${JSON.stringify(message)}\n`)
this.proc!.stdin.flush()
}
kill() {
this.proc?.kill()
}
/** 等程序自己结束、输出全部读完 */
async finished() {
await this.proc!.exited
await this.reading
return { code: this.proc!.exitCode, signal: this.proc!.signalCode }
}
}
function findByName(node: ControlNode, name: string): ControlNode {
if (node.name === name) return node
for (const child of node.children) {
const found = findByName(child, name)
if (found.name === name) return found
}
return { id: '', type: '', name: '', props: {}, children: [] }
}
/** 编译产物写在工程目录里,所以把示例复制到临时目录再编译 */
async function prepareSample(id: string) {
const dir = resolve(ROOT, '.runs', `test-${id}`)
await rm(dir, { recursive: true, force: true })
await cp(resolve(ROOT, 'samples', id), dir, { recursive: true })
return dir
}
/** 启动示例并等窗体显示;返回按控件名查 id 的函数 */
async function startSample(id: string) {
runner = await Runner.start(await prepareSample(id))
const compiled = await runner.waitFor('compiled')
expect(compiled.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
const { form } = await runner.waitFor('formShown')
return { runner, form, id: (name: string) => findByName(form, name).id }
}
const ANALOG_DEFAULT = [25, 60, 500, 101, 60, 600, 3]
const DIGITAL_OFF = [false, false, false, false]
let runner: Runner | undefined
afterEach(() => runner?.kill())
afterAll(() => compileService.stop())
test('项目2 任务1:选串口、打开、推送文字到 LED 屏', async () => {
runner = await Runner.start(await prepareSample('p2-t1-LED显示屏'))
const compiled = await runner.waitFor('compiled')
expect(compiled.diagnostics).toEqual([])
const { form } = await runner.waitFor('formShown')
expect(form.props.Text).toBe('LED显示屏')
const id = (name: string) => findByName(form, name).id
const comPort = id('com_Port')
// 先推送、不打开串口:教材约定返回 "Error:发送失败",学生代码弹出 MessageBox
runner.send({ type: 'action', id: id('txt_SendText'), action: 'text', value: '欢迎学习物联网' })
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
const box = await runner.waitFor('messageBox')
expect(box.text).toBe('Error:发送失败')
runner.send({ type: 'reply', requestId: box.requestId, result: 'OK' })
// 点下拉框:Click 事件里填充串口列表
runner.send({ type: 'action', id: comPort, action: 'dropDown' })
const items = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === comPort && c.prop === 'Items'))
const ports = items.changes.findLast((c) => c.id === comPort && c.prop === 'Items')!.value as string[]
expect(ports).toContain('COM5')
runner.send({ type: 'action', id: comPort, action: 'select', index: ports.indexOf('COM5') })
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
const device = await runner.waitFor('device', (m) => m.device === 'led')
expect(device.data).toBe('欢迎学习物联网')
const led = id('led')
const ledPatch = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === led && c.prop === 'LedText'))
expect(ledPatch.changes.find((c) => c.id === led)!.value).toBe('欢迎学习物联网')
// 关闭主窗体:程序结束
runner.send({ type: 'action', id: form.id, action: 'close' })
await runner.waitFor('formClosed')
}, 30000)
test('编译错误:书上常见的 True 大写给出中文提示', async () => {
const dir = resolve(ROOT, '.runs/test-true')
const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏')
for (const name of ['Program.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`))
const form1 = await Bun.file(`${sample}/Form1.cs`).text()
await Bun.write(`${dir}/Form1.cs`, form1.replace('private void btn_Open_Click(object sender, EventArgs e)\n {', 'private void btn_Open_Click(object sender, EventArgs e)\n {\n bool IsOpen = True;'))
runner = await Runner.start(dir)
const compiled = await runner.waitFor('compiled')
expect(compiled.ok).toBe(false)
const error = compiled.diagnostics.find((d) => d.code === 'CS0103')
expect(error?.message).toContain('True')
expect(error?.hint).toContain('小写')
}, 30000)
test('项目2 任务2:温度读数来自实训台,滑块变化后再次获取得到新值', async () => {
const { runner, id } = await startSample('p2-t2-模拟量采集')
const port = id('com_port')
// 串口列表在 Load 里就已填好
runner.send({ type: 'action', id: port, action: 'select', index: 1 })
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
const textOf = (name: string) =>
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && c.prop === 'Text')).then(
(m) => m.changes.findLast((c) => c.id === id(name) && c.prop === 'Text')!.value,
)
let temperature = textOf('txt_temperature')
runner.send({ type: 'action', id: id('btn_get'), action: 'click' })
expect(await temperature).toBe('25')
runner.send({ type: 'board', analog: [31, 60, 300, 101, 60, 600, 3], digital: DIGITAL_OFF })
temperature = textOf('txt_temperature')
const light = textOf('txt_light')
runner.send({ type: 'action', id: id('btn_get'), action: 'click' })
expect(await temperature).toBe('31')
expect(await light).toBe('300')
}, 30000)
test('项目3 任务1:单选按钮切换,事件顺序与 WinForms 一致,最终灯的状态正确', async () => {
const { runner, id } = await startSample('p3-t1-ZigBee路灯')
const lamp = id('lamp1')
// 串口没打开时控制无效:没有继电器动作,但界面上的灯照样会亮(学生能看出两者不一致)
runner.send({ type: 'action', id: id('rb_Open'), action: 'click' })
await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === lamp && c.prop === 'IsOn' && c.value === true))
expect(runner.messages.some((m) => m.type === 'device')).toBe(false)
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 3 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
// 选"关":先触发"开"的 CheckedChanged(又开一次),再触发"关"的 CheckedChanged,最终关灯
const relayOff = runner.next('device', (m) => m.device === 'zigbee' && (m.data as number[])[1] === 0)
runner.send({ type: 'action', id: id('rb_Close'), action: 'click' })
const devices: unknown[] = []
await relayOff
for (const m of runner.messages) if (m.type === 'device') devices.push(m.data)
expect(devices).toEqual([
[1, 1],
[1, 0],
])
}, 30000)
test('项目3 任务2:勾选人体开关后,有人经过时 5 秒内路灯打开', async () => {
const { runner, id } = await startSample('p3-t2-人体红外ZigBee联动')
runner.send({ type: 'action', id: id('com_lamp'), action: 'select', index: 3 })
runner.send({ type: 'action', id: id('com_environment'), action: 'select', index: 2 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
// 复选框的 Click 里读取 Checked:此时 Checked 必须已经是 true
const disabled = runner.next('patch', (m) => m.changes.some((c) => c.id === id('btn_lampOpen') && c.prop === 'Enabled' && c.value === false))
runner.send({ type: 'action', id: id('ch_bodyInfrared'), action: 'click' })
await disabled
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [true, false, false, false] })
const on = await runner.next('device', (m) => m.device === 'zigbee', 7000)
expect(on.data).toEqual([1, 1])
}, 30000)
test('项目1 任务1:多窗体——打开增加用户窗体、添加用户、关闭后登录成功,可以再次打开', async () => {
const { runner, form: login, id } = await startSample('p1-t1-登录')
expect(login.props.Text).toBe('登录')
runner.send({ type: 'action', id: id('btnAdd'), action: 'click' })
const { form: addUser } = await runner.waitFor('formShown', (m) => m.form.props.Text === '增加用户')
const addId = (name: string) => findByName(addUser, name).id
runner.send({ type: 'action', id: addId('txtNewUser1'), action: 'text', value: 'admin' })
runner.send({ type: 'action', id: addId('txtPass1'), action: 'text', value: '123' })
runner.send({ type: 'action', id: addId('txtPass2'), action: 'text', value: '123' })
runner.send({ type: 'action', id: addId('btnOk'), action: 'click' })
await runner.waitFor('formClosed', (m) => m.formId === addUser.id)
runner.send({ type: 'action', id: id('txtUserName'), action: 'text', value: 'admin' })
runner.send({ type: 'action', id: id('txtPassword'), action: 'text', value: '123' })
runner.send({ type: 'action', id: id('btnOk'), action: 'click' })
const box = await runner.waitFor('messageBox')
expect(box.text).toBe('登录成功,欢迎进入!')
runner.send({ type: 'reply', requestId: box.requestId, result: 'OK' })
// 再次打开增加用户窗体:每次新建实例,不会因为上一个已释放而出错
runner.send({ type: 'action', id: id('btnAdd'), action: 'click' })
const second = await runner.next('formShown', (m) => m.form.props.Text === '增加用户')
expect(second.form.id).not.toBe(addUser.id)
expect(runner.messages.some((m) => m.type === 'exception')).toBe(false)
}, 30000)
describe('所有示例都能编译通过', async () => {
const ids = (await Array.fromAsync(new Bun.Glob('*/meta.json').scan({ cwd: resolve(ROOT, 'samples') }))).map((p) => p.split('/')[0]).sort()
for (const id of ids) {
test(id, async () => {
const result = await compileService.compile(await prepareSample(id))
expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
}, 30000)
}
})
test('项目1 任务2:计算器(装箱拆箱)和多态', async () => {
const { runner, id } = await startSample('p1-t2-面向对象特性')
const textOf = (name: string) =>
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && c.prop === 'Text')).then(
(m) => m.changes.findLast((c) => c.id === id(name) && c.prop === 'Text')!.value,
)
runner.send({ type: 'action', id: id('txtNum1'), action: 'text', value: '7' })
runner.send({ type: 'action', id: id('txtNum2'), action: 'text', value: '2' })
runner.send({ type: 'action', id: id('cmboxType'), action: 'select', index: 3 })
const result = textOf('txtResult')
runner.send({ type: 'action', id: id('btnCalculate'), action: 'click' })
expect(await result).toBe('3.5')
const display = textOf('txtDispaly')
runner.send({ type: 'action', id: id('btnDisplay'), action: 'click' })
expect(await display).toBe('张三说的是汉语!\r\n李四说的是汉语!\r\nMary说的是英语!\r\nJimmy说的是英语!\r\n')
}, 30000)
/** 选 COM3、点"开启",等定时器读到实训台上的传感器 */
async function openAdam4150(runner: Runner, id: (name: string) => string) {
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 2 })
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
}
test('项目4 任务1:红外对射被遮挡时显示"发现入侵"', async () => {
const { runner, id } = await startSample('p4-t1-红外对射')
await openAdam4150(runner, id)
const even = (value: string) => runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === value), 5000)
await even('正常')
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
await even('发现入侵')
}, 30000)
test('项目4 任务2:打开摄像机显示画面,转动后画面变化,有人翻墙时画面变化,拍照保存到 Camera 文件夹', async () => {
const { runner, id } = await startSample('p4-t2-网络摄像机')
const pictures = () => runner.next('patch', (m) => m.changes.some((c) => c.id === id('pictureBox1') && c.prop === 'Image'), 5000)
const imageOf = (patch: Extract<RunnerMessage, { type: 'patch' }>) =>
patch.changes.find((c) => c.id === id('pictureBox1') && c.prop === 'Image')!.value as { key: string; width: number; height: number }
runner.send({ type: 'action', id: id('txt_ip'), action: 'text', value: '192.168.2.50' })
const first = pictures()
const cameraOn = runner.next('device', (m) => m.device === 'camera' && m.action === 'on' && m.data === true)
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await cameraOn
const center = imageOf(await first)
expect(center).toMatchObject({ width: 640, height: 480 })
// 图片数据先于(或随)属性变化发给浏览器,同一张图只发一次
const sent = runner.messages.filter((m) => m.type === 'image' && m.key === center.key)
expect(sent.length).toBe(1)
expect(sent[0]).toMatchObject({ mime: 'image/jpeg' })
const up = pictures()
const pan = runner.next('device', (m) => m.device === 'camera' && m.action === 'pan')
runner.send({ type: 'action', id: id('btn_up'), action: 'click' })
expect((await pan).data).toEqual([3, 0])
expect(imageOf(await up).key).not.toBe(center.key)
const intruder = pictures()
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
const withIntruder = imageOf(await intruder)
const saved = runner.next('imageSaved')
runner.send({ type: 'action', id: id('btn_camera'), action: 'click' })
const photo = await saved
expect(photo.path).toMatch(/^Camera\/\d{8}_\d{2} \d{2} \d{2}\.jpg$/)
expect(photo.image).toBe(withIntruder.key)
}, 30000)
test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => {
const { runner, id } = await startSample('p4-t3-报警灯')
await openAdam4150(runner, id)
const relay = runner.next('device', (m) => m.device === 'adam4150')
const lamp = runner.next('patch', (m) => m.changes.some((c) => c.id === id('alarm1') && c.prop === 'IsOn' && c.value === true))
runner.send({ type: 'action', id: id('btn_alarmOpen'), action: 'click' })
expect((await relay).data).toEqual([0, 1])
await lamp
}, 30000)
test('项目5 任务1:有火焰时显示"发现火情!"', async () => {
const { runner, id } = await startSample('p5-t1-火焰')
await openAdam4150(runner, id)
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, true, false, false] })
await runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === '发现火情!'), 5000)
}, 30000)
test('项目5 任务2:线程实时显示烟雾数据;关闭时线程挂起、再打开时继续;关闭窗体后程序正常结束', async () => {
const { runner, id, form } = await startSample('p5-t2-烟雾')
// 改写后的代码不再用过时的 API,不应出现"已过时"警告
expect((await runner.waitFor('compiled')).diagnostics).toEqual([])
const changed = (name: string, match: (value: unknown) => boolean = () => true, timeout = 5000) =>
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && match(c.value)), timeout)
const normal = changed('txt_even', (v) => v === '正常')
await openAdam4150(runner, id)
await normal
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, true, false] })
await changed('txt_even', (v) => v === '发现烟情')
// 关闭:线程挂起,时间不再更新
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('btn_open', (v) => v === '开启')
await expect(changed('txt_time', undefined, 3000)).rejects.toThrow('超时')
// 再打开:线程继续
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('txt_time', undefined, 3000)
// 挂起后关闭窗体:先 Resume 再 Abort,程序正常结束
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await changed('btn_open', (v) => v === '开启')
runner.send({ type: 'action', id: form.id, action: 'close' })
expect((await runner.finished()).code).toBe(0)
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
}, 30000)
test('项目5 任务2:照书上直接 Abort 挂起的线程,和 .NET Framework 一样报错,并提示先 Resume', async () => {
const dir = await prepareSample('p5-t2-烟雾')
const file = `${dir}/Form1.cs`
const code = await Bun.file(file).text()
await Bun.write(file, code.replace(/if \(thd\.ThreadState == ThreadState\.Suspended\)\s*\{\s*thd\.Resume\(\);\s*\}/, ''))
runner = await Runner.start(dir)
const { form } = await runner.waitFor('formShown')
const id = (name: string) => findByName(form, name).id
await openAdam4150(runner, id)
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === id('btn_open') && c.value === '开启'))
runner.send({ type: 'action', id: form.id, action: 'close' })
const error = await runner.next('exception')
expect(error.message).toContain('ThreadStateException')
expect(error.message).toContain('先调用 Resume()')
}, 30000)
/** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */
async function runMain(name: string, body: string, usings = '') {
const dir = resolve(ROOT, '.runs', `test-${name}`)
await rm(dir, { recursive: true, force: true })
await Bun.write(
`${dir}/Program.cs`,
`using System;\nusing System.IO;\nusing System.Linq;\n${usings}\nstatic class Program\n{\n static void Main()\n {\n${body}\n }\n}\n`,
)
runner = await Runner.start(dir)
return { runner, dir }
}
test('旧版线程 API:Suspend / Resume / Abort、线程自己挂起、休眠中被终止、ThreadState', async () => {
const { runner } = await runMain(
'legacy-threads',
`
int count = 0;
Thread worker = new Thread(() => { while (true) { count++; Thread.Sleep(20); } });
Console.WriteLine("state0=" + worker.ThreadState);
worker.Start();
Thread.Sleep(200);
worker.Suspend();
Thread.Sleep(100);
int frozen = count;
Console.WriteLine("state1=" + worker.ThreadState);
Thread.Sleep(200);
Console.WriteLine("frozen=" + (count == frozen));
try { worker.Abort(); } catch (ThreadStateException) { Console.WriteLine("abortSuspended=error"); }
worker.Resume();
Console.WriteLine("joined=" + worker.Join(1000) + "," + worker.ThreadState);
Thread self = null;
self = new Thread(() => { self.Suspend(); Console.WriteLine("self=resumed"); });
self.Start();
Thread.Sleep(100);
Console.WriteLine("selfState=" + self.ThreadState);
self.Resume();
self.Join(1000);
Thread sleeper = new Thread(new ThreadStart(Sleepy));
sleeper.Start();
Thread.Sleep(100);
try { sleeper.Resume(); } catch (ThreadStateException) { Console.WriteLine("resumeRunning=error"); }
sleeper.Abort();
Console.WriteLine("sleeperJoined=" + sleeper.Join(1000));
Thread unstarted = new Thread(Sleepy);
unstarted.Abort();
Console.WriteLine("unstarted=" + unstarted.ThreadState);
var param = new Thread(o => Console.WriteLine("param=" + o));
param.Start(42);
param.Join();
}
static void Sleepy()
{
Thread.Sleep(100000);
Console.WriteLine("sleeper=woke");`,
'using System.Threading;',
)
expect((await runner.waitFor('compiled')).ok).toBe(true)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
for (const line of [
'state0=Unstarted', 'state1=Suspended', 'frozen=True', 'abortSuspended=error', 'joined=True,Stopped',
'selfState=Suspended', 'self=resumed', 'resumeRunning=error', 'sleeperJoined=True', 'unstarted=Unstarted, AbortRequested', 'param=42',
]) {
expect(output).toContain(line)
}
expect(output).not.toContain('sleeper=woke')
}, 30000)
test('ListBox:点某一项先选中(SelectedIndexChanged)再触发 Click,Text 是选中项;双击触发 DoubleClick', async () => {
const { runner } = await runMain(
'listbox',
`
var form = new Form();
var list = new ListBox { Name = "list" };
list.Items.AddRange(new object[] { "甲", "乙", "丙" });
list.SelectedIndexChanged += (s, e) => Console.WriteLine("changed=" + list.SelectedIndex + "," + list.Text);
list.Click += (s, e) => Console.WriteLine("click");
list.DoubleClick += (s, e) => { Console.WriteLine("double=" + list.SelectedItem); list.Items.RemoveAt(1); };
form.Controls.Add(list);
Application.Run(form);`,
'using System.Windows.Forms;',
)
const { form } = await runner.waitFor('formShown')
const list = findByName(form, 'list')
expect(list.props.Items).toEqual(['甲', '乙', '丙'])
runner.send({ type: 'action', id: list.id, action: 'select', index: 1 })
await runner.waitFor('console', (m) => m.text === 'click\n')
runner.send({ type: 'action', id: list.id, action: 'doubleClick' })
// 删掉选中的项后取消选中,浏览器收到新的列表
const patch = await runner.next('patch', (m) => m.changes.some((c) => c.prop === 'Items'))
expect(patch.changes).toContainEqual({ id: list.id, prop: 'SelectedIndex', value: -1 })
expect(patch.changes).toContainEqual({ id: list.id, prop: 'Items', value: ['甲', '丙'] })
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toBe('changed=1,乙\nclick\ndouble=乙\n')
}, 30000)
test('图片:程序所在目录可写,Image.Save / FromFile 往返,文件夹不存在时和 GDI+ 一样报一般性错误', async () => {
const { runner } = await runMain(
'image',
`
string dir = AppDomain.CurrentDomain.BaseDirectory;
Console.WriteLine("base=" + dir.EndsWith("/bin/Debug/") + "," + (Directory.GetCurrentDirectory() + "/" == dir));
// 1×1 的 PNG
byte[] png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
Image image = Image.FromStream(new MemoryStream(png));
Directory.CreateDirectory(dir + "Pics");
image.Save(dir + "Pics/a.png", ImageFormat.Png);
Image back = Image.FromFile("Pics/a.png");
Console.WriteLine("size=" + back.Width + "x" + back.Height + "," + back.RawFormat);
try { image.Save(dir + "NoSuchDir/b.png"); }
catch (System.Runtime.InteropServices.ExternalException ex) { Console.WriteLine("error=" + ex.Message); }`,
'using System.Drawing;\nusing System.Drawing.Imaging;',
)
expect((await runner.waitFor('compiled')).diagnostics.filter((d) => d.severity === 'error')).toEqual([])
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toContain('base=True,True')
expect(output).toContain('size=1x1,Png')
expect(output).toContain('error=GDI+ 中发生一般性错误。')
expect(runner.messages.filter((m) => m.type === 'imageSaved').map((m) => (m as { path: string }).path)).toEqual(['Pics/a.png'])
}, 30000)
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
process.env.SIM_TEST_SECRET = '不该被看到'
const { runner } = await runMain(
'sandbox',
`
Console.WriteLine("passwd=" + File.Exists("/etc/passwd"));
Console.WriteLine("runs=" + Directory.Exists(${JSON.stringify(config.runsDir)}));
Console.WriteLine("home=" + Directory.Exists("/home"));
Console.WriteLine("secret=" + (Environment.GetEnvironmentVariable("SIM_TEST_SECRET") ?? "null"));
Console.WriteLine("procs=" + Directory.GetDirectories("/proc").Count(d => char.IsDigit(Path.GetFileName(d)[0])));
// 工程目录在沙箱里挂在 /sim/app(当前目录是可写的程序目录 bin/Debug,不是工程目录)
try { File.WriteAllText("/sim/app/hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); }
File.WriteAllText("/tmp/ok.txt", "x");
Console.WriteLine("tmp=" + File.Exists("/tmp/ok.txt"));`,
)
expect((await runner.waitFor('compiled')).ok).toBe(true)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toContain('passwd=False')
expect(output).toContain('runs=False')
expect(output).toContain('home=False')
expect(output).toContain('secret=null')
// 只能看到 bwrap 和自己
expect(Number(/procs=(\d+)/.exec(output)?.[1])).toBeLessThanOrEqual(2)
expect(output).toContain('write=denied')
expect(output).toContain('tmp=True')
}, 30000)
test('内存上限:不停分配内存的程序得到 OutOfMemoryException,而不是吃光服务器内存', async () => {
const { runner } = await runMain(
'heap',
`
var blocks = new System.Collections.Generic.List<byte[]>();
try { while (true) blocks.Add(new byte[1 << 20]); }
catch (OutOfMemoryException) { Console.WriteLine("oom=" + blocks.Count); }`,
)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
const megabytes = Number(/oom=(\d+)/.exec(output)?.[1])
expect(megabytes).toBeGreaterThan(50)
expect(megabytes).toBeLessThanOrEqual(config.heapLimitMb)
}, 30000)
test('编译检查:网络、启动程序、反射、dynamic、AppDomain 给出中文报错;AssemblyInfo.cs 里的特性、BaseDirectory 不受影响', async () => {
const dir = resolve(ROOT, '.runs/test-policy')
await rm(dir, { recursive: true, force: true })
await Bun.write(
`${dir}/Program.cs`,
`using System;
using System.Diagnostics;
using System.Net.Http;
static class Program
{
static void Main()
{
Process.Start("sh");
var client = new HttpClient();
typeof(string).GetMethod("Trim", Type.EmptyTypes).Invoke("x", null);
dynamic d = 1;
string path = AppDomain.CurrentDomain.BaseDirectory + "Camera/";
AppDomain.CurrentDomain.GetAssemblies();
}
}
`,
)
await Bun.write(
`${dir}/Properties/AssemblyInfo.cs`,
`using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("WindowsFormsApplication1")]
[assembly: ComVisible(false)]
[assembly: Guid("0a1b2c3d-1111-2222-3333-444455556666")]
`,
)
const result = await compileService.compile(dir)
expect(result.ok).toBe(false)
const denied = result.diagnostics.filter((d) => d.code === 'SIM001')
expect(denied.map((d) => [d.line, d.message])).toEqual([
[8, '平台不允许使用 System.Diagnostics.Process.Start(启动其他程序)'],
[9, '平台不允许使用 System.Net.Http.HttpClient(网络访问)'],
[10, '平台不允许使用 System.Reflection.MethodBase.Invoke(反射)'],
[11, '平台不允许使用 dynamic(dynamic 动态调用)'],
[13, '平台不允许使用 System.AppDomain.GetAssemblies(加载程序集)'],
])
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
}, 30000)
test.skipIf(config.sandbox === 'none')('CPU 时间上限:死循环的程序到时被结束,并提示检查死循环', async () => {
const limit = config.cpuLimitSeconds
config.cpuLimitSeconds = 2
try {
const { runner } = await runMain('cpu', ' long i = 0;\n while (true) i++;')
const { code, signal } = await runner.finished()
expect(cpuLimitMessage(code, signal)).toContain('死循环')
} finally {
config.cpuLimitSeconds = limit
}
}, 30000)