refactor!: separate runtime releases from panel source
This commit is contained in:
+12
-24
@@ -1,15 +1,10 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { api, normalBuildAllowed, selectableImage, type Image, type Instance } from './api';
|
||||
import { message, ResourceState, useAction, useResource } from './ui';
|
||||
import { api, selectableImage, type Instance } from './api';
|
||||
import { ResourceState, useAction, useResource } from './ui';
|
||||
|
||||
export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise<void>}) {
|
||||
const compatibility = useResource(api.compatibility);
|
||||
const images = useResource(api.images), action = useAction();
|
||||
const [mode, setMode] = useState('local'), [progress, setProgress] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
const [pollError, setPollError] = useState('');
|
||||
const [intents, setIntents] = useState<{id:string; name:string; version:string; status:string; error:string}[]>([]);
|
||||
useEffect(() => {let live=true; const load=()=>api.request<typeof intents>('/intents').then(v=>{if(live){setIntents(v);setPollError('');}}).catch(e=>{if(live)setPollError(message(e));}); void load(); const timer=window.setInterval(load,2000); return ()=>{live=false;window.clearInterval(timer);};}, []);
|
||||
const [progress, setProgress] = useState('');
|
||||
const active = useRef(true);
|
||||
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
|
||||
const available = images.data?.filter(selectableImage) || [];
|
||||
@@ -17,21 +12,7 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget), name = String(form.get('name'));
|
||||
await action.run(async () => {
|
||||
let image: Image | undefined = available.find(i => (i.image_id || i.id) === form.get('image'));
|
||||
if (mode === 'latest') {
|
||||
if (!version) {
|
||||
const latest = await api.request<{version:string; build_allowed:boolean}>('/latest');
|
||||
if (!latest.version) throw new Error('服务器未返回精确版本。');
|
||||
setVersion(latest.version);
|
||||
if (!latest.build_allowed || !normalBuildAllowed(await api.compatibility(), latest.version)) throw new Error('此最新版不支持普通构建。管理员可前往设置 → 镜像与构建进行实验构建,但实验镜像不能直接用于创建。');
|
||||
return;
|
||||
}
|
||||
if (!normalBuildAllowed(await api.compatibility(), version)) throw new Error('此版本不支持普通构建;实验镜像不能直接用于创建。');
|
||||
const intent = await api.request<{id:string}>('/intents', 'POST', {name, version});
|
||||
if (!intent.id) throw new Error('未收到意图确认,请刷新核实,勿重复提交。');
|
||||
setIntents(await api.request<typeof intents>('/intents'));
|
||||
await refresh(); return;
|
||||
}
|
||||
const image = available.find(i => (i.image_id || i.id) === form.get('image'));
|
||||
if (!active.current) return;
|
||||
if (!image) throw new Error('请选择可用的本地镜像。');
|
||||
setProgress('正在创建实例,请勿重复提交…');
|
||||
@@ -41,5 +22,12 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
|
||||
});
|
||||
if (active.current) setProgress('');
|
||||
}
|
||||
return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}<p className="callout">确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。</p><fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label><label>创建方式<select value={mode} onChange={e => {setMode(e.target.value); setVersion('');}}><option value="local">选择本地镜像</option><option value="latest">构建最新版并创建</option></select></label>{mode === 'local' && <><ResourceState resource={images}/><label>本地镜像<select name="image" required defaultValue=""><option value="" disabled>选择已构建镜像</option>{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}</select></label>{!images.loading && !available.length && <p>暂无已准入的可用镜像。历史或实验镜像不可用于创建。</p>}</>}{mode === 'latest' && <ResourceState resource={compatibility}/>} {mode === 'latest' && version && compatibility.data && !normalBuildAllowed(compatibility.data, version) && <p className="alert">此最新版不支持普通构建。管理员可前往设置 → 镜像与构建实验构建;实验镜像不能直接用于创建。</p>}{mode === 'latest' && version && compatibility.data && normalBuildAllowed(compatibility.data, version) && <p>将锁定精确版本 <strong>{version}</strong>,确认后预留名额并构建创建。</p>}<button className="primary" disabled={mode === 'local' ? !available.length : !!version && (!compatibility.data || !normalBuildAllowed(compatibility.data, version))}>{mode === 'local' ? '确认创建' : version ? '确认版本并预留创建' : '查询最新版'}</button></fieldset>{progress && <p role="status">{progress}</p>}{pollError && <p className="muted" role="alert">意图刷新失败:{pollError}</p>}<ul>{intents.map(i => <li key={i.id}>{i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && <button type="button" disabled={action.busy} onClick={() => void action.run(async () => {await api.request('/intents/' + i.id, 'DELETE'); setIntents(await api.request<typeof intents>('/intents')); await refresh();})}>取消等待并释放名额</button>}{i.status === 'success' && <button type="button" onClick={() => void refresh()}>刷新实例列表</button>}</li>)}</ul><button type="button" disabled={action.busy} onClick={close}>关闭(保留已提交意图)</button></form>;
|
||||
return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}
|
||||
<p className="callout">请选择已验证并准入的本地镜像。创建使用所选镜像的精确 ID,不会自动构建或切换版本。</p>
|
||||
<fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label>
|
||||
<ResourceState resource={images}/><label>本地镜像<select name="image" required defaultValue=""><option value="" disabled>选择已验证镜像</option>{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}</select></label>
|
||||
{!images.loading && !available.length && <p>暂无已准入的可用镜像。请联系管理员构建并验证发布镜像。</p>}
|
||||
<button className="primary" disabled={!available.length}>确认创建</button>
|
||||
</fieldset>{progress && <p role="status">{progress}</p>}<button type="button" disabled={action.busy} onClick={close}>关闭</button>
|
||||
</form>;
|
||||
}
|
||||
|
||||
+27
-21
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
|
||||
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, emptyImages: false}));
|
||||
vi.mock('react', async original => ({
|
||||
...await original<typeof import('react')>(),
|
||||
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
|
||||
@@ -8,22 +8,21 @@ vi.mock('react', async original => ({
|
||||
}));
|
||||
vi.mock('./ui', async original => ({
|
||||
...await original<typeof import('./ui')>(),
|
||||
useResource: (load: unknown) => ({data: load === api.compatibility ? policy : load === api.images ? [{id:'ok', version:'1.2.3', status:'available', selectable:true}, {id:'exp', version:'9.9.9', status:'experimental', selectable:false}] : [], loading:false, refresh:vi.fn(async () => {})}),
|
||||
useResource: (load: unknown) => ({data: load === api.images ? state.emptyImages ? [] : [{id:'ok', version:'1.2.3', status:'available', selectable:true}, {id:'exp', version:'9.9.9', status:'experimental', selectable:false}] : [], loading:false, refresh:vi.fn(async () => {})}),
|
||||
}));
|
||||
import { api, type Compatibility } from './api';
|
||||
import { api } from './api';
|
||||
import { Images } from './Images';
|
||||
import { ReleasePanel } from './ReleasePanel';
|
||||
import { CreateInstance } from './CreateInstance';
|
||||
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
|
||||
import { Instances } from './Instances';
|
||||
const policy: Compatibility = {recipe_rev:'7', platform:'linux/amd64', entries:[], unknown:{state:'existing_only', build_allowed:false, reason:'unknown'}, experimental_admin_only:true};
|
||||
function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
||||
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
|
||||
if (!root || typeof root !== 'object') return [];
|
||||
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||
}
|
||||
function render(admin = false) {state.cursor = 0; return Images({admin});}
|
||||
beforeEach(() => {state.slots = []; state.cursor = 0; vi.stubGlobal('window', {confirm:vi.fn(() => false)});});
|
||||
beforeEach(() => {state.slots = []; state.cursor = 0; state.emptyImages = false; vi.stubGlobal('window', {confirm:vi.fn(() => false)});});
|
||||
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
|
||||
it('hides experimental and admission controls from ordinary users', () => {
|
||||
const tree = render();
|
||||
@@ -33,12 +32,10 @@ it('hides experimental and admission controls from ordinary users', () => {
|
||||
expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0);
|
||||
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2);
|
||||
});
|
||||
it('canceled build confirmation sends zero requests', () => {
|
||||
const request = vi.spyOn(api, 'startBuild');
|
||||
const check = vi.spyOn(api, 'compatibility');
|
||||
nodes(render(true), n => n.type === 'form')[0].props.onSubmit({preventDefault:vi.fn()});
|
||||
expect(window.confirm).toHaveBeenCalledOnce();
|
||||
expect(request).not.toHaveBeenCalled(); expect(check).not.toHaveBeenCalled();
|
||||
it.each([false, true])('has no legacy build controls (admin=%s)', admin => {
|
||||
const tree = render(admin);
|
||||
expect(nodes(tree, n => n.type === 'form' || n.type === 'input')).toHaveLength(0);
|
||||
expect(JSON.stringify(tree)).not.toMatch(/查询 latest|精确 DSH 版本|提交实验构建/);
|
||||
});
|
||||
it('requires evidence and confirmation; cancel sends no admission mutation', () => {
|
||||
const request = vi.spyOn(api, 'admitImage');
|
||||
@@ -53,17 +50,26 @@ it('requires evidence and confirmation; cancel sends no admission mutation', ()
|
||||
form.props.onSubmit({preventDefault:vi.fn()});
|
||||
expect(window.confirm).toHaveBeenCalledOnce(); expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
it('unknown latest never reserves an intent, including a second submit', async () => {
|
||||
const request = vi.spyOn(api, 'request').mockResolvedValue({version:'9.9.9', build_allowed:false});
|
||||
vi.spyOn(api, 'compatibility').mockResolvedValue(policy);
|
||||
vi.stubGlobal('FormData', class {get() {return 'name';}});
|
||||
const renderCreate = () => {state.cursor = 0; return CreateInstance({close:vi.fn(), refresh:vi.fn(async () => {})});};
|
||||
let form = renderCreate();
|
||||
it('creates only the selected verified exact image without build or intent requests', async () => {
|
||||
const request = vi.spyOn(api, 'request').mockResolvedValue({id:'created'});
|
||||
vi.stubGlobal('FormData', class {get(key: string) {return key === 'image' ? 'ok' : 'name';}});
|
||||
const close = vi.fn(), refresh = vi.fn(async () => {});
|
||||
const form = CreateInstance({close, refresh});
|
||||
expect(nodes(form, n => n.type === 'select')).toHaveLength(1);
|
||||
expect(nodes(form, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0);
|
||||
nodes(form, n => n.type === 'select')[0].props.onChange({target:{value:'latest'}});
|
||||
form = renderCreate(); await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
|
||||
form = renderCreate(); await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
|
||||
expect(request.mock.calls).toEqual([['/latest']]);
|
||||
await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
|
||||
expect(request.mock.calls).toEqual([['/instances', 'POST', {name:'name', image_id:'ok', version:'1.2.3'}]]);
|
||||
expect(refresh).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('directs users to an administrator and disables creation when no verified image exists', async () => {
|
||||
state.emptyImages = true;
|
||||
const request = vi.spyOn(api, 'request');
|
||||
vi.stubGlobal('FormData', class {get() {return 'exp';}});
|
||||
const form = CreateInstance({close:vi.fn(), refresh:vi.fn(async () => {})});
|
||||
expect(nodes(form, n => n.type === 'button' && n.props.children === '确认创建')[0].props.disabled).toBe(true);
|
||||
expect(JSON.stringify(form)).toContain('请联系管理员');
|
||||
await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
it('admission rejects blank/unconfirmed evidence, resets consent on edits, and submits once', async () => {
|
||||
const request = vi.spyOn(api, 'admitImage').mockResolvedValue({});
|
||||
|
||||
+8
-25
@@ -1,5 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { api, normalBuildAllowed, type AdmissionState, type Build } from './api';
|
||||
import { useState } from 'react';
|
||||
import { api, type AdmissionState, type Build } from './api';
|
||||
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
|
||||
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
|
||||
export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status);
|
||||
@@ -25,7 +25,7 @@ function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<vo
|
||||
<option value="blocked">禁止使用</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="callout">构建成功不等于通过验收。请记录启动、登录、终端、文件和配置等实际兼容性验收结果及可追溯依据,不可仅填写“构建成功”。单镜像准入不扩大普通构建版本白名单;未知版本仅可选择已准入的本地镜像 ID 创建或升级。</p>
|
||||
<p className="callout">构建成功不等于通过验收。请记录启动、登录、终端、文件和配置等实际兼容性验收结果及可追溯依据,不可仅填写“构建成功”。创建或升级须选择已准入的本地镜像 ID。</p>
|
||||
<label>验收依据<textarea required aria-invalid={evidenceBytes > 4096} value={evidence} onChange={e => {setEvidence(e.target.value); setConfirmed(false);}}/>
|
||||
</label>
|
||||
<p className={evidenceBytes > 4096 ? 'alert' : 'muted'} role={evidenceBytes > 4096 ? 'alert' : undefined}>验收依据:{evidenceBytes} / 4096 UTF-8 字节(去除首尾空白后);中文通常占 3 字节,表情通常占 4 字节。{evidenceBytes > 4096 && '已超出字节上限,请缩短依据。'}</p>
|
||||
@@ -35,33 +35,16 @@ function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<vo
|
||||
</form>;
|
||||
}
|
||||
export function Images({admin = false}: {admin?:boolean}) {
|
||||
const [experimental, setExperimental] = useState(false), [admissionId, setAdmissionId] = useState<string>();
|
||||
const [admissionId, setAdmissionId] = useState<string>();
|
||||
const [page, setPage] = useState(0);
|
||||
const compatibility = useResource(api.compatibility);
|
||||
const images = useResource(api.images), builds = useResource(() => api.builds(21, page * 20), String(page), 5000, rows => rows.slice(0, 20).some(activeBuild)); const action = useAction();
|
||||
const [latest, setLatest] = useState<{version:string; checked_at?:string}>(), [version,setVersion] = useState(''), [selected,setSelected] = useState<string>();
|
||||
function build(e: FormEvent) {e.preventDefault(); void action.run(async () => {if (!(admin && experimental) && !normalBuildAllowed(await api.compatibility(), version)) throw new Error('此版本不支持普通构建,请管理员选择实验构建;实验构建不会自动准入。'); await api.startBuild(version, admin && experimental); if (page === 0) await builds.refresh(); else setPage(0);}, '构建任务已提交,请查看任务的实际状态。', (experimental ? '实验构建不会自动准入或用于创建。确认实验构建精确版本 ' : '确认在本机手动构建精确版本 ') + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');}
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const rows = builds.data?.slice(0, 20);
|
||||
function changePage(next: number) { setSelected(undefined); setPage(next); }
|
||||
return <>{action.feedback}{admin && <ReleasePanel onBuilt={async () => {if (page === 0) await builds.refresh(); else changePage(0);}}/>}<Panel title="查询与手动构建">
|
||||
<ResourceState resource={compatibility}/>{version && compatibility.data && !normalBuildAllowed(compatibility.data, version) && <p className="alert">此版本不支持普通构建。仅管理员可实验构建,不会自动准入。</p>}<div className="split">
|
||||
<div>
|
||||
<p className="muted">从 npm 查询 dist-tags.latest,仅查询,不自动构建。</p>
|
||||
<button disabled={action.busy} onClick={() => action.run(async () => {const result = await api.request<{version:string; checked_at?:string}>('/latest'); setLatest(result); setVersion(result.version);}, '最新版本查询完成')}>查询 latest</button>{latest && <p>
|
||||
<strong>{latest.version}</strong> <small>查询时间 {date(latest.checked_at)}</small>
|
||||
</p>}</div>
|
||||
<form className="build-form" onSubmit={build}>
|
||||
<label>精确 DSH 版本<input required value={version} onChange={e => setVersion(e.target.value)} placeholder="查询 latest,或填写精确版本" pattern="[0-9]+\.[0-9]+\.[0-9]+.*" />
|
||||
</label>
|
||||
<>{admin && <label>
|
||||
<input type="checkbox" checked={experimental} onChange={e => setExperimental(e.target.checked)}/>管理员实验构建(不自动准入)</label>}{experimental && <p className="alert" role="alert">实验镜像:构建成功后仍不能创建或升级实例,必须另行验收并显式准入。</p>}<button className="primary" disabled={action.busy || !version}>{experimental ? '提交实验构建' : '手动构建'}</button>
|
||||
</>
|
||||
</form>
|
||||
</div>
|
||||
<p className="callout">镜像仅保存在本机。构建成功不等于通过验收或自动准入。仅 available 且允许选择的镜像可创建或升级;existing_only 仅允许存量启停。blocked 禁止新建、升级及新的启动/重启,但不会自动停止已运行实例或断开连接。</p>
|
||||
</Panel>
|
||||
return <>{action.feedback}{admin && <ReleasePanel onBuilt={async () => {if (page === 0) await builds.refresh(); else changePage(0);}}/>}
|
||||
<Panel title="本地镜像" action={<button disabled={images.loading} onClick={images.refresh}>刷新</button>}>
|
||||
<ResourceState resource={images}/>{images.data?.length === 0 && <Empty>本机暂无托管镜像,请先手动构建。</Empty>}{!!images.data?.length && <div className="table-scroll">
|
||||
<p className="callout">镜像仅保存在本机。仅已验证且允许选择的 available 镜像可创建或升级;existing_only 仅允许存量启停。blocked 禁止新建、升级及新的启动/重启,但不会自动停止已运行实例或断开连接。</p>
|
||||
<ResourceState resource={images}/>{images.data?.length === 0 && <Empty>本机暂无托管镜像。请联系管理员构建并验证发布镜像。</Empty>}{!!images.data?.length && <div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ReleasePanel({onBuilt}: {onBuilt: () => Promise<void>}) {
|
||||
`确认手动构建源码发布 ${current.version}?\n仓库:${current.repository}\n分支:${current.branch}\n提交:${current.commit}\n部署协议:${current.recipe_rev}\n平台:${current.platforms.join(', ')}\n构建会消耗磁盘、网络与 CPU;不会自动升级实例,构建成功不等于通过验收。`);
|
||||
}
|
||||
return <Panel title="源码发布(管理员)" action={<button disabled={release.loading || action.busy} onClick={release.refresh}>刷新源码发布</button>}>
|
||||
<p className="muted">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级;与 npm latest / 实验构建相互独立。</p>
|
||||
<p className="muted">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级。新构建由管理员在此提交,创建或升级须另行选择已验证的镜像。</p>
|
||||
<ResourceState resource={release}/>{associated && <ResourceState resource={task}/>} {action.feedback}
|
||||
{current && <>
|
||||
<h3>{current.version}</h3>
|
||||
|
||||
@@ -9,9 +9,6 @@ export interface Image { id: string; image_id?: string; version: string; tag?: s
|
||||
export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string; release_id?: string; source_commit?: string; source_repository?: string }
|
||||
export interface Release { release_id: string; version: string; commit: string; repository: string; branch: string; recipe_rev: string; platforms: string[]; compatible: boolean; reason: string; build?: Build }
|
||||
export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true;
|
||||
export interface CompatibilityRule { state: string; build_allowed: boolean; reason: string }
|
||||
export interface Compatibility { recipe_rev: string; platform: string; entries: (CompatibilityRule & {version:string; recipe_rev:string; platform:string})[]; unknown: CompatibilityRule; experimental_admin_only: boolean }
|
||||
export const normalBuildAllowed = (policy: Compatibility, version: string) => policy.entries.some(entry => entry.version === version && entry.recipe_rev === policy.recipe_rev && entry.platform === policy.platform && entry.build_allowed === true);
|
||||
export interface Entry { name: string; path?: string; is_dir: boolean; size: number; mod_time: string }
|
||||
export interface Usage { used_bytes: number; quota_bytes: number }
|
||||
export interface Backup { id: string; created_at: string; size: number }
|
||||
@@ -84,10 +81,8 @@ export function createApi(fetcher: typeof fetch = fetch) {
|
||||
async logout() { await request('/logout', 'POST'); csrf = ''; },
|
||||
instances: () => request<Instance[]>('/instances'),
|
||||
images: () => request<Image[]>('/images'),
|
||||
compatibility: () => request<Compatibility>('/compatibility'),
|
||||
latestRelease: () => request<Release>('/releases/latest'),
|
||||
buildRelease: (commit: string) => request<Build>('/releases/build', 'POST', {commit}),
|
||||
startBuild: (version: string, experimental = false) => request<Build>('/builds', 'POST', {version, experimental}),
|
||||
admitImage: (image_id: string, state: AdmissionState, evidence: string) => request('/images/admission', 'POST', {image_id, state, evidence}),
|
||||
builds: (limit = 20, offset = 0) => request<Build[]>(`/builds?limit=${limit}&offset=${offset}`),
|
||||
build: (id: string) => request<Build>('/builds/' + encodeURIComponent(id)),
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { createApi, normalBuildAllowed, selectableImage, type AdmissionState, type Compatibility } from './api';
|
||||
import { createApi, selectableImage, type AdmissionState } from './api';
|
||||
|
||||
it('reads the compatibility contract and accepts only explicit current tuples', async () => {
|
||||
const policy: Compatibility = {recipe_rev:'7', platform:'linux/amd64', entries:[{version:'1.2.3', recipe_rev:'7', platform:'linux/amd64', state:'recommended', build_allowed:true, reason:'tested'}], unknown:{state:'existing_only', build_allowed:false, reason:'unknown'}, experimental_admin_only:true};
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify(policy)));
|
||||
const result = await createApi(fetcher).compatibility();
|
||||
expect(fetcher.mock.calls[0][0]).toBe('/api/compatibility');
|
||||
expect(result).toEqual(policy);
|
||||
expect(normalBuildAllowed(result, '1.2.3')).toBe(true);
|
||||
expect(normalBuildAllowed(result, '9.9.9')).toBe(false);
|
||||
expect(normalBuildAllowed({...result, recipe_rev:'8'}, '1.2.3')).toBe(false);
|
||||
expect(normalBuildAllowed({...result, platform:'linux/arm64'}, '1.2.3')).toBe(false);
|
||||
expect(normalBuildAllowed({...result, entries:result.entries.map(entry => ({...entry, build_allowed:false}))}, '1.2.3')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends exact experimental build and evidence-bearing admission contracts', async () => {
|
||||
it('sends exact-image evidence-bearing admission contracts', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{}'));
|
||||
const api = createApi(fetcher);
|
||||
await api.startBuild('1.2.3');
|
||||
await api.startBuild('9.9.9', true);
|
||||
for (const state of ['recommended', 'compatible', 'existing_only', 'blocked'] as AdmissionState[]) {
|
||||
await api.admitImage('sha256:a/b', state, '终端、文件与配置验收记录 #42');
|
||||
}
|
||||
expect(fetcher.mock.calls.map(([url, options]) => [url, options?.method, JSON.parse(options?.body as string)])).toEqual([
|
||||
['/api/builds', 'POST', {version:'1.2.3', experimental:false}],
|
||||
['/api/builds', 'POST', {version:'9.9.9', experimental:true}],
|
||||
...['recommended', 'compatible', 'existing_only', 'blocked'].map(state => ['/api/images/admission', 'POST', {image_id:'sha256:a/b', state, evidence:'终端、文件与配置验收记录 #42'}]),
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user