Invalidate stale artifact diagnostics on detail refresh

This commit is contained in:
mateaix 2026-09-14 01:50:04 +08:00
parent d154044cfc
commit c3d1c7febf
2 changed files with 65 additions and 6 deletions

View File

@ -17,7 +17,15 @@ const checkFields = ref<Record<string, string>>({})
const checkLoading = ref<Record<string, boolean>>({})
const checkErrors = ref<Record<string, string>>({})
const checkResults = ref<Record<string, ArtifactJsonCheck>>({})
let checkGeneration: Record<string, number> = {}
function invalidateCheck(id: string) {
checkGeneration[id] = (checkGeneration[id] ?? 0) + 1
delete checkLoading.value[id]
delete checkErrors.value[id]
delete checkResults.value[id]
}
function clearChecks() {
checkGeneration = {}
checkFields.value = {}
checkLoading.value = {}
checkErrors.value = {}
@ -66,6 +74,7 @@ async function loadDetail(id: string, event: Event) {
if (!(event.target as HTMLDetailsElement).open || detailLoading.value[id]) return
const request = generation
detailLoading.value[id] = true
invalidateCheck(id)
delete detailErrors.value[id]
try {
const { data } = await executionEvidenceApi.get(id)
@ -86,7 +95,7 @@ async function loadDetail(id: string, event: Event) {
}
}
async function checkJson(id: string) {
if (checkLoading.value[id]) return
if (loading.value || detailLoading.value[id] || checkLoading.value[id]) return
const fields = (checkFields.value[id] ?? '').split(/\r?\n/).filter(field => field.length > 0)
delete checkResults.value[id]
delete checkErrors.value[id]
@ -96,17 +105,19 @@ async function checkJson(id: string) {
return
}
const request = generation
const checkRequest = checkGeneration[id] ?? 0
const isCurrent = () => request === generation && checkRequest === (checkGeneration[id] ?? 0)
checkLoading.value[id] = true
try {
const { data } = await executionEvidenceApi.checkJson(id, fields)
if (request !== generation || !items.value.some(item => item.id === id)) return
if (!isCurrent() || !items.value.some(item => item.id === id)) return
checkResults.value[id] = data
if (data.status === 'UNAVAILABLE') {
items.value = items.value.map(item => item.id === id
? { ...item, artifactRef: null, artifactDigest: null, summary: null, validity: 'UNAVAILABLE' } : item)
}
} catch (error) {
if (request !== generation) return
if (!isCurrent()) return
const failure = error as { code?: number; response?: { status?: number } }
const code = failure.response?.status ?? failure.code
if (code === 401 || code === 403 || code === 404) {
@ -117,7 +128,7 @@ async function checkJson(id: string) {
checkErrors.value[id] = code === 400 ? 'executionEvidence.jsonCheck.inputError' : 'executionEvidence.loadError'
}
} finally {
if (request === generation) delete checkLoading.value[id]
if (isCurrent()) delete checkLoading.value[id]
}
}
function toggle() {
@ -178,10 +189,10 @@ onBeforeUnmount(() => { generation++ })
<form v-if="item.kind === 'ARTIFACT_SNAPSHOT' && item.artifactRef" class="json-check" @submit.prevent="checkJson(item.id)">
<label :for="`json-fields-${item.id}`">{{ t('executionEvidence.jsonCheck.label') }}</label>
<textarea :id="`json-fields-${item.id}`" v-model="checkFields[item.id]" data-json-fields rows="3" maxlength="2064"
:disabled="!!checkLoading[item.id]" :placeholder="t('executionEvidence.jsonCheck.placeholder')"
:disabled="loading || !!detailLoading[item.id] || !!checkLoading[item.id]" :placeholder="t('executionEvidence.jsonCheck.placeholder')"
@input="delete checkResults[item.id]" />
<p>{{ t('executionEvidence.jsonCheck.scope') }}</p>
<button type="submit" data-json-check :disabled="!!checkLoading[item.id]">{{ t(checkLoading[item.id] ? 'common.loading' : 'executionEvidence.jsonCheck.run') }}</button>
<button type="submit" data-json-check :disabled="loading || !!detailLoading[item.id] || !!checkLoading[item.id]">{{ t(checkLoading[item.id] ? 'common.loading' : 'executionEvidence.jsonCheck.run') }}</button>
</form>
<p v-if="checkErrors[item.id]" role="alert">{{ t(checkErrors[item.id]) }}</p>
<div v-if="checkResults[item.id]" data-json-result role="status">

View File

@ -125,6 +125,54 @@ describe('execution evidence', () => {
expect(host.querySelector('[data-json-result]')).toBeNull()
expect(host.textContent).not.toContain('All listed fields are present')
})
it.each([false, true])('invalidates JSON results when artifact details are refreshed (pending=%s)', async (pending) => {
const artifact = { ...row('artifact'), kind: 'ARTIFACT_SNAPSHOT', artifactRef: 'file', artifactDigest: 'hash' }
const result = { data: { status: 'MATCH', missingFields: [], checkedAt: 'old', acceptanceEligible: false } }
let resolveCheck!: (value: unknown) => void
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [artifact], nextCursor: null } } as never)
vi.mocked(executionEvidenceApi.get).mockResolvedValue({ data: { ...artifact, validity: 'STALE' } } as never)
vi.mocked(executionEvidenceApi.checkJson).mockImplementationOnce(() => new Promise(r => { resolveCheck = r }) as never)
.mockResolvedValueOnce({ data: { ...result.data, status: 'STALE', checkedAt: 'new' } } as never)
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
const field = host.querySelector<HTMLTextAreaElement>('[data-json-fields]')!
field.value = 'report'; field.dispatchEvent(new Event('input')); await flush()
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
if (!pending) {
resolveCheck(result); await flush()
expect(host.querySelector('[data-json-result]')?.textContent).toContain('All listed fields are present')
}
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
expect(host.textContent).toContain(en.executionEvidence.validity.STALE)
if (pending) { resolveCheck(result); await flush() }
expect(host.querySelector('[data-json-result]')).toBeNull()
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
expect(executionEvidenceApi.checkJson).toHaveBeenCalledTimes(2)
expect(host.querySelector('[data-json-result]')?.textContent).toContain('new')
expect(host.querySelector('[data-json-result]')?.textContent).not.toContain('All listed fields are present')
})
it('ignores an obsolete check rejection while a replacement check is pending', async () => {
const artifact = { ...row('artifact'), kind: 'ARTIFACT_SNAPSHOT', artifactRef: 'file' }
let rejectOld!: (reason: unknown) => void
let resolveNew!: (value: unknown) => void
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [artifact], nextCursor: null } } as never)
vi.mocked(executionEvidenceApi.get).mockResolvedValue({ data: artifact } as never)
vi.mocked(executionEvidenceApi.checkJson).mockImplementationOnce(() => new Promise((_, reject) => { rejectOld = reject }) as never)
.mockImplementationOnce(() => new Promise(resolve => { resolveNew = resolve }) as never)
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
const field = host.querySelector<HTMLTextAreaElement>('[data-json-fields]')!
field.value = 'report'; field.dispatchEvent(new Event('input')); await flush()
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush()
expect(executionEvidenceApi.checkJson).toHaveBeenCalledTimes(2)
rejectOld({ code: 403 }); await flush()
expect(host.querySelector('[data-evidence-item]')).not.toBeNull()
expect(host.querySelector<HTMLButtonElement>('[data-json-check]')!.disabled).toBe(true)
expect(host.querySelector('[role="alert"]')).toBeNull()
resolveNew({ data: { status: 'MATCH', missingFields: [], checkedAt: 'new', acceptanceEligible: false } }); await flush()
expect(host.querySelector('[data-json-result]')?.textContent).toContain('new')
expect(host.querySelector<HTMLButtonElement>('[data-json-check]')!.disabled).toBe(false)
})
it('rejects empty JSON requirements locally without reading the file', async () => {
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [{ ...row('artifact'), kind: 'ARTIFACT_SNAPSHOT', artifactRef: 'file' }], nextCursor: null } } as never)
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()