Files
xas-test-library/console/static/scripts.js
T

276 lines
19 KiB
JavaScript
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.
import {api, el, button, link, badge, note, empty, loading, field, input, textarea, select, heading, kv, errorBox, toast, dialog, date, enc, scriptUrl} from './ui.js';
import {parseArgs, parseLines, requestId} from './core.js';
import {runTable} from './runs.js';
import {relatedFiles} from './related-files.js';
import {isNative, maxTimeout, nativeParameterForm, nativeScriptInfo, preflightNativeRun} from './native-ui.js';
const pendingRuns = new Map();
function pendingRequest(id) {
if (pendingRuns.has(id)) return pendingRuns.get(id);
let value;
try { value = sessionStorage.getItem(`xas.pending-run.${id}`); } catch { /* Private browser settings can disable storage. */ }
if (!value || !/^[0-9a-f-]{36}$/.test(value)) value = requestId();
pendingRuns.set(id, value);
try { sessionStorage.setItem(`xas.pending-run.${id}`, value); } catch { /* The in-memory request still supports safe retry. */ }
return value;
}
function clearRequest(id) {
pendingRuns.delete(id);
try { sessionStorage.removeItem(`xas.pending-run.${id}`); } catch { /* Storage may be unavailable. */ }
}
const scriptState = script => !script.enabled && script.status === 'ready' ? 'disabled' : script.status;
const scriptBadge = script => badge(scriptState(script), scriptState(script) === 'disabled' ? '未启用' : undefined);
const sourceLabel = script => script.kind === 'managed' ? '托管脚本' : '外部原文件';
const profileLabel = script => isNative(script) ? '服务器原环境' : script.config.profile === 'legacy_smoke' ? '旧版 Smoke 兼容环境' : '标准运行环境';
const typeLabel = script => script.category === 'example' ? '验收示例' : script.test_kind === 'suite' ? '测试套件' : '单项测试';
const groupLabels = new Map([
['uploaded', '上传的脚本'], ['company-gpu-suite', '公司 GPU 测试套件'],
['dsv41-trial-20261010', 'DeepSeek 试验脚本(10/10)'], ['model-trials-20261011', '模型试验脚本(10/11)']
]);
export async function scriptList(ctx) {
ctx.show(heading('测试项目', '按实际测试行为管理,一项对应一次完整测试。'), loading());
try {
const {items} = await api('/api/scripts?include_examples=true', {}, ctx.signal);
if (!ctx.alive) return;
const search = input({type: 'search', placeholder: '搜索测试名称、用途或入口', 'aria-label': '搜索测试'});
const group = select('来源目录', [['', '全部来源'], ...Array.from(new Set(items.map(item => item.source_group || '未分组'))).sort().map(name => [name, groupLabels.get(name) || name])]);
const status = select('脚本状态', [['', '全部状态'], ['ready', '可运行'], ['needs_configuration', '待配置'], ['missing', '源文件缺失'], ['disabled', '未启用']]);
const kind = select('测试类型', [['', '全部类型'], ['suite', '测试套件'], ['test', '单项测试']]);
const purpose = select('项目用途', [['test', '业务测试'], ['example', '验收示例'], ['', '全部项目']], 'test');
const results = el('div');
const count = el('span', {class: 'muted', role: 'status'});
const discover = button('↻ 刷新来源', async () => {
discover.disabled = true;
try {
const result = await api('/api/discover', {method: 'POST'}, ctx.signal);
if (ctx.alive) {
toast(`目录已刷新:${result.tests} 项测试。内部文件更新不会自动新增测试项目。`);
await scriptList(ctx);
}
} catch (error) { if (ctx.alive) { toast(error.message); discover.disabled = false; } }
});
ctx.show(heading('测试项目', '一项对应一次测试;套件内部的多个脚本统一在详情中查看。', [discover, button('+ 新增测试', () => uploadDialog(ctx), 'primary')]),
el('div', {class: 'filters'}, el('div', {class: 'search'}, search), purpose, kind, status, group),
el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '按测试行为组织 · 验收示例默认隐藏'), count), results);
const render = () => {
const term = search.value.trim().toLowerCase();
const filtered = items.filter(item => (!group.value || (item.source_group || '未分组') === group.value)
&& (!status.value || scriptState(item) === status.value) && (!kind.value || item.test_kind === kind.value)
&& (!purpose.value || item.category === purpose.value)
&& (!term || [item.name, item.description, item.source_path].some(value => (value || '').toLowerCase().includes(term))));
count.textContent = `${filtered.length} 项测试`;
results.replaceChildren();
if (!filtered.length) { results.append(empty(items.length ? '没有匹配的测试项目' : '从第一个测试开始', items.length ? '调整关键词或筛选条件。' : '上传一个测试的入口脚本,创建测试项目。')); return; }
const groups = new Map();
for (const item of filtered) {
const name = item.category === 'example' ? '控制台验收示例' : item.test_kind === 'suite' ? '测试套件' : '单项测试';
if (!groups.has(name)) groups.set(name, []);
groups.get(name).push(item);
}
for (const [name, scripts] of groups) results.append(el('section', {},
el('div', {class: 'section-title'}, el('h2', {}, name, el('span', {class: 'count'}, `${scripts.length} 项`))),
el('div', {class: 'catalog-grid'}, scripts.map(scriptCard))));
};
[search, group, status, kind, purpose].forEach(control => control.addEventListener('input', render));
render();
} catch (error) { if (ctx.alive) ctx.show(heading('测试项目', '查看、配置与运行测试。'), errorBox(error, () => scriptList(ctx))); }
}
function scriptCard(script) {
return el('a', {class: 'script-card', href: `#/scripts/${enc(script.id)}`, 'aria-label': script.name},
el('div', {class: 'card-top'}, el('span', {class: `language ${script.language}`}, typeLabel(script)), scriptBadge(script)),
el('h3', {}, script.name),
el('p', {class: 'script-description'}, script.description || '尚未填写说明。'),
el('p', {class: 'card-source', title: script.source_path}, script.source_path),
script.reason ? el('p', {class: 'card-reason'}, script.reason) : null,
el('div', {class: 'card-bottom'}, el('span', {}, `${script.file_count} 个相关文件 · v${script.version}`), el('span', {class: 'button detail-link'}, '查看项目详情 →')));
}
function uploadDialog(ctx, script) {
const file = input({type: 'file', accept: '.py,.sh', required: '', name: 'file'});
const name = input({name: 'name', required: '', maxlength: 120, placeholder: '例如:模型接口连通性检查'});
const description = textarea({name: 'description', placeholder: '说明测试目标、运行条件和输出结果'});
const feedback = el('div', {'aria-live': 'polite'});
const submit = el('button', {class: 'button primary', type: 'submit'}, script ? '上传新版本' : '创建脚本');
const form = el('form', {}, note(script ? '新版本单独保存,不覆盖历史源码。后续运行使用最新版本,已有运行记录保持原版本。' : '支持 Python 和 Shell。脚本代码只读;后续修改请上传新版本。'),
field('脚本文件', file, '选择 .py 或 .sh 文件。'),
script ? null : field('脚本名称', name), script ? null : field('说明', description), feedback,
el('div', {class: 'form-footer'}, submit));
const panel = dialog(script ? `上传新版本 · ${script.name}` : '上传新脚本', form);
form.addEventListener('submit', async event => {
event.preventDefault();
if (submit.disabled || !form.reportValidity()) return;
const data = new FormData(form);
submit.disabled = true;
submit.textContent = '正在上传…';
feedback.replaceChildren();
try {
const saved = await api(script ? `${scriptUrl(script.id)}/versions` : '/api/scripts/upload', {method: 'POST', body: data}, ctx.signal);
if (!ctx.alive) return;
panel.close();
toast(script ? '新版本已保存。' : '脚本已创建。');
if (location.hash === `#/scripts/${enc(saved.id)}`) await scriptDetail(ctx, saved.id);
else location.hash = `/scripts/${enc(saved.id)}`;
} catch (error) {
if (ctx.alive) feedback.replaceChildren(errorBox(error), note('如果连接中断,请先关闭窗口并刷新脚本库,核对是否已保存,再决定重新上传。'));
} finally { submit.disabled = false; submit.textContent = script ? '上传新版本' : '创建脚本'; }
});
}
export async function scriptDetail(ctx, id) {
const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回测试项目', '#/scripts', 'button back-button'));
ctx.show(navigation(), loading('正在读取脚本与版本…'));
try {
const script = await api(scriptUrl(id), {}, ctx.signal);
if (!ctx.alive) return;
if (!script.is_test) {
const source = el('section', {class: 'panel'});
ctx.show(navigation(), heading(script.name, '这是测试内部的源码文件。'), note('请从测试项目的执行入口配置和运行。'), source);
sourcePanel(ctx, source, script);
return;
}
let dirty = false;
const feedback = el('div', {'aria-live': 'polite'});
const start = button('▶ 开始运行', async () => {
if (start.disabled) return;
start.disabled = true;
start.textContent = '正在提交…';
feedback.replaceChildren();
let prepared;
try {
if (isNative(script)) {
start.textContent = '正在预检…';
prepared = await preflightNativeRun(ctx, script);
if (!prepared || !ctx.alive || dirty) return;
start.textContent = '正在提交…';
}
const run = await api(`${scriptUrl(id)}/runs`, {method: 'POST', body: {request_id: pendingRequest(id), ...prepared?.payload}}, ctx.signal);
clearRequest(id);
if (ctx.alive) location.hash = `/runs/${enc(run.id)}`;
} catch (error) {
if (ctx.alive) feedback.replaceChildren(errorBox(error), note(isNative(script)
? '再次点击会重新预检并重新确认影响,同时复用本次请求编号核对原记录,避免重复排队。'
: '再次点击会复用本次请求编号,核对原运行记录,避免重复排队。'));
} finally {
prepared?.close();
if (ctx.alive) { start.disabled = dirty || !script.enabled || script.status !== 'ready'; start.textContent = '▶ 开始运行'; }
}
}, 'primary');
start.disabled = !script.enabled || script.status !== 'ready';
const actions = [link('运行记录', `#/runs?script_id=${enc(id)}`, 'button')];
if (script.kind === 'managed') actions.push(button('上传新版本', () => uploadDialog(ctx, script)));
actions.push(start);
const source = el('section', {class: 'panel'});
const history = el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '近期运行')), loading());
ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('测试项目', '#/scripts'), ' / ', script.name),
heading(script.name, script.description || '查看源代码、配置参数并开始一次独立运行。', actions),
feedback,
script.reason ? note(script.reason, script.status === 'missing' ? 'danger' : '') : null,
!script.enabled ? note('当前脚本未启用。确认运行参数和环境要求后,在配置中启用并保存。') : null,
nativeScriptInfo(script),
el('div', {class: 'detail-grid'}, source, configForm(ctx, script, () => {
dirty = true;
start.disabled = true;
start.title = '请先保存修改后的运行配置。';
})),
relatedFiles(ctx, script), el('div', {class: 'section-title'}, el('h2', {}, '执行记录')), history);
sourcePanel(ctx, source, script);
const loadHistory = async () => {
try {
const {items} = await api(`/api/runs?script_id=${enc(id)}&limit=10`, {}, ctx.signal);
if (ctx.alive) history.replaceChildren(items.length ? runTable(items) : empty('还没有运行记录', '首次运行后,可在这里回看状态、日志和结果文件。'));
} catch (error) { if (ctx.alive) history.replaceChildren(errorBox(error, loadHistory)); }
};
await loadHistory();
} catch (error) { if (ctx.alive) ctx.show(navigation(), heading('脚本详情', '读取脚本信息失败。'), errorBox(error, () => scriptDetail(ctx, id))); }
}
function sourcePanel(ctx, target, script) {
const versions = script.versions?.length ? script.versions : [{version: script.version}];
const version = select('查看源码版本', versions.map(item => [item.version, `版本 ${item.version}${String(item.version) === String(script.version) ? ' · 当前' : ''}`]), script.version);
const download = link('下载源码', '', 'button small');
const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '脚本源代码,只读'}, '正在读取源码…');
const info = el('div', {class: 'source-info mono'});
const feedback = el('div');
target.append(el('div', {class: 'panel-header'}, el('h2', {}, script.is_test ? '执行入口(代码只读)' : '内部源码(只读)'), scriptBadge(script)),
kv([['来源类型', sourceLabel(script)], ['源文件', script.source_path], ['运行环境', profileLabel(script)], ['更新时间', date(script.updated_at)]]),
el('div', {class: 'source-header'}, version, download), info, feedback, code,
el('p', {class: 'source-info'}, '代码只读。查看历史版本不改变运行版本;“开始运行”使用当前已保存版本与配置。'));
let generation = 0;
const load = async () => {
const ticket = ++generation;
code.textContent = '正在读取源码…';
download.hidden = true;
info.textContent = '';
feedback.replaceChildren();
try {
const data = await api(`${scriptUrl(script.id)}/code?version=${enc(version.value)}`, {}, ctx.signal);
if (!ctx.alive || ticket !== generation) return;
code.textContent = data.code || '(空文件)';
info.textContent = `${data.filename} · v${version.value}\nSHA-256 ${data.sha256}`;
download.href = `${scriptUrl(script.id)}/download?version=${enc(version.value)}`;
download.hidden = Boolean(data.redacted);
if (data.redacted) feedback.append(note('源码中包含敏感内容,预览已脱敏,下载不可用。'));
if (data.truncated) feedback.append(note(data.redacted ? '文件较大,当前仅显示部分已脱敏源码。' : '文件较大,当前仅显示部分源码。可下载完整文件。'));
} catch (error) {
if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, load)); }
}
};
version.addEventListener('change', load);
load();
}
function configForm(ctx, script, onDirty) {
const config = script.config;
const native = isNative(script);
const limit = maxTimeout(script);
const parameters = native ? nativeParameterForm(script) : null;
const name = input({value: script.name, required: '', maxlength: 120});
const description = textarea({value: script.description || ''});
const args = textarea({value: JSON.stringify(config.args, null, 2), rows: 4, class: 'input mono', spellcheck: 'false'});
const cwd = input({value: native ? script.execution.cwd || config.cwd || '' : config.cwd || '', placeholder: script.kind === 'managed' ? '留空:使用本次运行目录' : '原脚本工作目录'});
const outputs = textarea({value: (config.output_paths || []).join('\n'), class: 'input mono', spellcheck: 'false', placeholder: '每行一个日志或结果路径'});
if (native) [args, cwd, outputs].forEach(control => control.setAttribute('readonly', ''));
const timeout = input({type: 'number', value: config.timeout_seconds, required: '', min: '5', max: String(limit), step: '1'});
const enabled = input({type: 'checkbox', checked: script.enabled});
const feedback = el('div', {'aria-live': 'polite'});
const submit = el('button', {type: 'submit', class: 'button primary'}, '保存配置');
const form = el('form', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行配置')),
field('脚本名称', name), field('说明', description), parameters?.node,
field(native ? '原入口参数(只读)' : '参数', args, native ? '参数由原环境方案计算;可调整的选项在上方填写,实际命令以本次预检为准。' : 'JSON 字符串数组,或每行一个参数;不会交给 shell 拼接。'),
field(native ? '原工作目录(只读)' : '工作目录', cwd, native ? '固定使用登记的原目录。' : script.kind === 'managed' ? '托管脚本留空。可通过 XAS_RUN_DIR 获取本次结果目录。' : '必须位于已登记的来源目录内。'),
field(native ? '原输出规则(只读)' : '归档输出路径', outputs, native ? '由固定方案管理;运行前预检显示本次输出规则,保留原脚本的输出行为。' : script.kind === 'managed' ? '托管脚本留空;本次结果目录统一归档,.log 文件的新增内容进入实时日志。' : '登记该来源 logs/ 或 results/ 内已存在的文件或目录,每行一项。.log 新增内容进入实时日志,其他结果结束后归档。'),
field('超时时间(秒)', timeout, `填写 5–${limit} 秒之间的整数。${native ? '原环境方案最长支持 72 小时。' : ''}`), el('label', {class: 'checkbox'}, enabled, '启用此脚本'),
native ? note('复用原账号、解释器、目录与已有配置,不修改原项目文件。实际运行按原脚本逻辑产生输出与影响。')
: note(`${profileLabel(script)}。提供 Python 标准库、Bash 和 curl;额外依赖不会自动安装。`),
!native && script.config.profile === 'legacy_smoke' ? note('兼容配置由服务端管理,只读。代理请求有固定上限,不能在此调整服务或模型。', 'info') : null,
feedback, el('div', {class: 'form-footer'}, submit));
form.addEventListener('input', () => {
onDirty();
feedback.replaceChildren(note('配置有未保存的修改。保存后才能开始运行。'));
});
form.addEventListener('submit', async event => {
event.preventDefault();
if (submit.disabled || !form.reportValidity()) return;
feedback.replaceChildren();
let payload;
try {
const seconds = Number(timeout.value);
if (!Number.isSafeInteger(seconds) || seconds < 5 || seconds > limit) throw new Error(`超时时间必须是 5–${limit} 秒之间的整数。`);
if (!name.value.trim()) throw new Error('请填写脚本名称。');
payload = {name: name.value.trim(), description: description.value, enabled: enabled.checked,
config: native ? {native_options: parameters.read(), timeout_seconds: seconds}
: {args: parseArgs(args.value), cwd: cwd.value.trim(), output_paths: parseLines(outputs.value), timeout_seconds: seconds}};
} catch (error) { feedback.replaceChildren(errorBox(error)); return; }
submit.disabled = true;
submit.textContent = '正在保存…';
try {
await api(scriptUrl(script.id), {method: 'PATCH', body: payload}, ctx.signal);
if (ctx.alive) { toast('配置已保存,后续运行将使用新配置。'); await scriptDetail(ctx, script.id); }
} catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); }
finally { submit.disabled = false; submit.textContent = '保存配置'; }
});
return form;
}