From 3843dc04b7c80a3377e960a30852108a3fda9e42 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 13 Sep 2026 21:30:39 +0800 Subject: [PATCH] feat(evidence): inspect artifact version changes on demand --- .../evidence/ExecutionEvidenceProperties.java | 3 + .../ExecutionEvidenceQueryService.java | 16 +++- .../tool/document/GeneratedFileCache.java | 73 ++++++++++++++++--- .../src/main/resources/application.yml | 2 + .../evidence/ExecutionEvidenceQueryTest.java | 28 +++++++ .../GeneratedFileArtifactVersionTest.java | 69 ++++++++++++++++++ .../execution/ExecutionEvidenceList.vue | 33 ++++++++- .../__tests__/ExecutionEvidenceList.test.ts | 32 +++++++- 8 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java index e089b309..d17f342c 100644 --- a/mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java @@ -9,6 +9,9 @@ public class ExecutionEvidenceProperties { public enum Mode { OFF, OBSERVE, ENFORCE } private Mode mode = Mode.OBSERVE; private int retentionDays = 90; + private int artifactVersionCheckMaxBytes = 1_048_576; + public int getArtifactVersionCheckMaxBytes() { return artifactVersionCheckMaxBytes; } + public void setArtifactVersionCheckMaxBytes(int value) { artifactVersionCheckMaxBytes = Math.clamp(value, 0, 16_777_216); } private int cleanupMaxBatches = 10; public int getCleanupMaxBatches() { return cleanupMaxBatches; } public void setCleanupMaxBatches(int value) { cleanupMaxBatches = Math.clamp(value, 1, 100); } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java index 364da694..2ba09bb0 100644 --- a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java @@ -68,7 +68,7 @@ public class ExecutionEvidenceQueryService { if (page.isEmpty()) return new Page(List.of(), null); var attempts = store.findAttempts(canonicalWorkspace, conversationId, page.stream().map(ExecutionEvidence::attemptId).distinct().toList()); - return new Page(page.stream().map(row -> view(username, row, attempts.get(row.attemptId()))).toList(), + return new Page(page.stream().map(row -> view(username, row, attempts.get(row.attemptId()), false)).toList(), hasMore ? encode(page.getLast()) : null); } finally { metrics.timer("mateclaw.execution.evidence.query.latency").record( @@ -81,7 +81,7 @@ public class ExecutionEvidenceQueryService { ExecutionEvidence row = store.findById(id).orElseThrow(this::hidden); Long canonicalWorkspace = authorize(username, workspaceId, row.conversationId()); if (!canonicalWorkspace.equals(row.workspaceId())) throw hidden(); - return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden)); + return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), true); } private Long authorize(String username, Long workspaceId, String conversationId) { @@ -94,7 +94,7 @@ public class ExecutionEvidenceQueryService { return conversation.getWorkspaceId(); } - private View view(String username, ExecutionEvidence row, ExecutionAttempt attempt) { + private View view(String username, ExecutionEvidence row, ExecutionAttempt attempt, boolean inspectVersion) { if (attempt == null || !Objects.equals(attempt.id(), row.attemptId())) throw hidden(); if (!Objects.equals(attempt.identity().workspaceId(), row.workspaceId()) || !Objects.equals(attempt.identity().conversationId(), row.conversationId())) throw hidden(); @@ -116,6 +116,16 @@ public class ExecutionEvidenceQueryService { } else if (!files.isDurablyAvailable(artifact, row.workspaceId(), row.conversationId())) { validity = "UNAVAILABLE"; artifact = null; + } else if (inspectVersion && !"UNAVAILABLE".equals(validity)) { + var version = files.probeDurableArtifactVersion(artifact, row.workspaceId(), row.conversationId(), + digest, properties.getArtifactVersionCheckMaxBytes()); + if (version == GeneratedFileCache.ArtifactVersion.CHANGED) { + validity = "STALE"; + } else if (version == GeneratedFileCache.ArtifactVersion.UNAVAILABLE) { + validity = "UNAVAILABLE"; + artifact = null; + } + // Equality/budget exhaustion remains UNKNOWN; there is no managed generation fence. } } metrics.counter("mateclaw.execution.evidence.validity", "status", validity).increment(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index 17fbbbdb..76c21ca2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -21,6 +21,8 @@ import java.time.Instant; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.StandardOpenOption; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; @@ -396,23 +398,74 @@ public class GeneratedFileCache { } } + /** No positive verification state: a matching shared file is still unverified. */ + public enum ArtifactVersion { UNVERIFIED, CHANGED, UNAVAILABLE } + /** Bounded metadata-only probe. Availability does not imply that current content is verified. */ public boolean isDurablyAvailable(String id, Long workspaceId, String conversationId) { - if (id == null || !ID_RE.matcher(id).matches()) return false; - Path bin = storageDir.resolve(id).normalize(); - Path meta = storageDir.resolve(id + META_SUFFIX).normalize(); try { - if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin) - || !Files.isRegularFile(meta) || Files.size(meta) > 16_384) return false; - Metadata stored = parseMeta(Files.readString(meta), id); - return stored.expireAt() > System.currentTimeMillis() - && Objects.equals(workspaceId, stored.workspaceId()) - && Objects.equals(conversationId, stored.conversationId()); - } catch (Exception unavailable) { + return availableMetadata(id, workspaceId, conversationId) != null; + } catch (IOException | RuntimeException unavailable) { return false; } } + /** + * On-demand bounded comparison with a historical snapshot, never a freshness certificate. + * A changed digest is useful negative evidence; equality cannot exclude concurrent writers. + */ + public ArtifactVersion probeDurableArtifactVersion(String id, Long workspaceId, String conversationId, + String expectedDigest, int maxBytes) { + try { + Metadata before = availableMetadata(id, workspaceId, conversationId); + if (before == null) return ArtifactVersion.UNAVAILABLE; + int budget = Math.clamp(maxBytes, 0, 16_777_216); + if (budget == 0 || expectedDigest == null || !expectedDigest.matches("[0-9a-fA-F]{64}")) { + return ArtifactVersion.UNVERIFIED; + } + Path bin = storageDir.resolve(id); + if (Files.size(bin) > budget) return ArtifactVersion.UNVERIFIED; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + int total = 0; + byte[] buffer = new byte[8192]; + try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + int read; + // At most budget+1 bytes even if a concurrent writer grows the file. + while ((read = input.read(buffer, 0, Math.min(buffer.length, budget + 1 - total))) != -1) { + total += read; + if (total > budget) return ArtifactVersion.UNVERIFIED; + digest.update(buffer, 0, read); + } + } + Metadata after = availableMetadata(id, workspaceId, conversationId); + if (after == null) return ArtifactVersion.UNAVAILABLE; + if (!before.equals(after)) return ArtifactVersion.UNVERIFIED; + return expectedDigest.equalsIgnoreCase(HexFormat.of().formatHex(digest.digest())) + ? ArtifactVersion.UNVERIFIED : ArtifactVersion.CHANGED; + } catch (IOException | RuntimeException unavailable) { + return ArtifactVersion.UNAVAILABLE; + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private Metadata availableMetadata(String id, Long workspaceId, String conversationId) throws IOException { + if (id == null || !ID_RE.matcher(id).matches()) return null; + Path bin = storageDir.resolve(id).normalize(); + Path meta = storageDir.resolve(id + META_SUFFIX).normalize(); + if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin, LinkOption.NOFOLLOW_LINKS) + || !Files.isRegularFile(meta, LinkOption.NOFOLLOW_LINKS)) return null; + byte[] raw; + try (var input = Files.newInputStream(meta, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + raw = input.readNBytes(16_385); + } + if (raw.length > 16_384) return null; + Metadata stored = parseMeta(new String(raw, StandardCharsets.UTF_8), id); + return stored.expireAt() > System.currentTimeMillis() + && Objects.equals(workspaceId, stored.workspaceId()) + && Objects.equals(conversationId, stored.conversationId()) ? stored : null; + } + private Entry loadFromDisk(String id) { Path bin = storageDir.resolve(id).normalize(); Path meta = storageDir.resolve(id + META_SUFFIX).normalize(); diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 88935553..d850d1e7 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -134,6 +134,8 @@ mateclaw: execution-evidence: # Observe receipts only. Enforcement requires managed verification scopes and is not available yet. mode: observe + # On-demand detail probe only; matching bytes remain UNKNOWN without managed scopes. 0 disables. + artifact-version-check-max-bytes: 1048576 retention-days: 90 max-summary-bytes: 2048 max-observations: 32 diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java index c72e6d2c..8945a191 100644 --- a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java @@ -100,6 +100,7 @@ class ExecutionEvidenceQueryTest { assertEquals("UNAVAILABLE", unavailable.validity()); assertNull(unavailable.artifactRef()); assertNull(unavailable.artifactDigest()); + verifyNoInteractions(files); } @Test void emptyPageDoesNotLoadAttempts() { @@ -135,6 +136,33 @@ class ExecutionEvidenceQueryTest { () -> list("owner", 1L, "conv", null, null)).getCode()); } + @Test void detailDetectsChangedPersistedArtifact(@org.junit.jupiter.api.io.TempDir java.nio.file.Path root) throws Exception { + var cache = new GeneratedFileCache(root); + byte[] bytes = "original report".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String artifactId = cache.put(bytes, "report.txt", "text/plain", new GeneratedFileCache.Owner(1L, 1L, "conv")); + String digest = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + var observation = new EvidenceObservation("artifact:" + artifactId, EvidenceKind.ARTIFACT_SNAPSHOT, + EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, + null, artifactId, digest, "registered file", null, Instant.now(), Instant.now().plusSeconds(3600)); + when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation))); + var auth = mock(AuthService.class); + var admin = new vip.mate.auth.model.UserEntity(); + admin.setId(1L); admin.setRole("admin"); + when(auth.findByUsername("owner")).thenReturn(admin); + var properties = new ExecutionEvidenceProperties(); + queries = new ExecutionEvidenceQueryService(store, conversations, teams, cache, auth, + mock(WorkspaceService.class), properties, new SimpleMeterRegistry()); + assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity()); + java.nio.file.Files.writeString(root.resolve(artifactId), "externally replaced"); + assertEquals("STALE", queries.detail("owner", 1L, id).validity()); + when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull())) + .thenReturn(List.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation))); + assertEquals("UNKNOWN", list("owner", 1L, "conv", null, 20).items().getFirst().validity(), + "pagination must remain metadata-only"); + properties.setArtifactVersionCheckMaxBytes(0); + assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity()); + } + private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) { return queries.list(user, workspace, conversation, cursor, limit, null, null); } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java new file mode 100644 index 00000000..28bd6da8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java @@ -0,0 +1,69 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; + +import static org.junit.jupiter.api.Assertions.*; +import static vip.mate.tool.document.GeneratedFileCache.ArtifactVersion.*; + +class GeneratedFileArtifactVersionTest { + @TempDir Path root; + + private String put(GeneratedFileCache cache, String text) { + return cache.put(text.getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain", + new GeneratedFileCache.Owner(1L, 1L, "conv")); + } + private String digest(String text) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); + } + + @Test void matchingBytesRemainUnverifiedAndChangedBytesAreDetected() throws Exception { + var cache = new GeneratedFileCache(root); + String id = put(cache, "report"); + assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024)); + Files.writeString(root.resolve(id), "different report"); + assertEquals(CHANGED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024)); + assertEquals("report", new String(cache.get(id).orElseThrow().bytes(), StandardCharsets.UTF_8), + "probe reads durable bytes, not the old hot cache"); + } + + @Test void overBudgetDisabledAndInvalidDigestNeverPassOrClaimChange() throws Exception { + var cache = new GeneratedFileCache(root); + String id = put(cache, "report"); + assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("different"), 2)); + assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("different"), 0)); + assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", "bad digest", 1024)); + } + + @Test void missingForeignExpiredAndOversizedMetadataAreUnavailable() throws Exception { + var cache = new GeneratedFileCache(root); + String id = put(cache, "report"); + assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 2L, "conv", digest("report"), 1024)); + assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "other", digest("report"), 1024)); + Path meta = root.resolve(id + ".meta"); + String original = Files.readString(meta); + Files.writeString(meta, "0" + original.substring(original.indexOf('\t'))); + assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024)); + Files.writeString(meta, "x".repeat(16_385)); + assertFalse(cache.isDurablyAvailable(id, 1L, "conv")); + Files.writeString(meta, original); + Files.delete(root.resolve(id)); + assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024)); + } + + @org.junit.jupiter.api.condition.EnabledOnOs({org.junit.jupiter.api.condition.OS.LINUX, org.junit.jupiter.api.condition.OS.MAC}) + @Test void symbolicLinksAreNotFollowedByTheProbe() throws Exception { + var cache = new GeneratedFileCache(root); + String id = put(cache, "report"); + Path outside = Files.writeString(root.resolve("outside"), "report"); + Files.delete(root.resolve(id)); + Files.createSymbolicLink(root.resolve(id), outside); + assertFalse(cache.isDurablyAvailable(id, 1L, "conv")); + assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024)); + } +} diff --git a/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue b/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue index 60fdded8..ae6511fe 100644 --- a/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue +++ b/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue @@ -11,11 +11,15 @@ const loading = ref(false) const errorKey = ref('') const items = ref([]) const nextCursor = ref(null) +const detailLoading = ref>({}) +const detailErrors = ref>({}) let generation = 0 async function load(more = false) { if (loading.value || !props.conversationId) return const request = ++generation + detailLoading.value = {} + detailErrors.value = {} loading.value = true errorKey.value = '' try { @@ -38,6 +42,29 @@ async function load(more = false) { if (request === generation) loading.value = false } } +async function loadDetail(id: string, event: Event) { + if (!(event.target as HTMLDetailsElement).open || detailLoading.value[id]) return + const request = generation + detailLoading.value[id] = true + delete detailErrors.value[id] + try { + const { data } = await executionEvidenceApi.get(id) + if (request !== generation) return + items.value = items.value.map(item => item.id === id ? data : item) + } catch (error) { + if (request !== generation) return + const failure = error as { code?: number; response?: { status?: number } } + const code = failure.response?.status ?? failure.code + if (code === 401 || code === 403 || code === 404) { + items.value = items.value.filter(item => item.id !== id) + errorKey.value = 'executionEvidence.accessError' + } else { + detailErrors.value[id] = 'executionEvidence.loadError' + } + } finally { + if (request === generation) delete detailLoading.value[id] + } +} function toggle() { expanded.value = !expanded.value if (expanded.value && !loaded.value) void load() @@ -45,6 +72,8 @@ function toggle() { watch(() => [props.conversationId, props.goalId, props.teamTaskId], () => { generation++ items.value = [] + detailLoading.value = {} + detailErrors.value = {} nextCursor.value = null errorKey.value = '' loaded.value = false @@ -80,8 +109,10 @@ onBeforeUnmount(() => { generation++ })
{{ t('executionEvidence.observedAt') }}
{{ t('executionEvidence.expiresAt') }}
-
+
{{ t('executionEvidence.details') }} +

{{ t('common.loading') }}

+

{{ t(detailErrors[item.id]) }}

{{ t('executionEvidence.id') }}
{{ item.id }}
{{ t('executionEvidence.attemptId') }}
{{ item.attemptId }}
diff --git a/mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts b/mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts index 3fae0803..b59da2bd 100644 --- a/mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts +++ b/mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import ExecutionEvidenceList from '../ExecutionEvidenceList.vue' import { executionEvidenceApi } from '@/api/executionEvidence' import en from '@/i18n/locales/en-US' -vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn() } })) +vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn(), get: vi.fn() } })) const apps: ReturnType[] = [] const row = (id: string) => ({ id, attemptId: '9223372036854775801', conversationId: 'one', toolName: 'execCommand', state: 'SUCCEEDED', effectOutcome: 'CONFIRMED', kind: 'COMMAND_EXIT', result: 'OBSERVED', sourceLevel: 'RUNTIME', validity: 'UNKNOWN', summary: 'Exit code: 0', observedAt: '2026-09-07T12:00:00Z', expiresAt: null, artifactRef: null, artifactDigest: null }) async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() } @@ -42,4 +42,34 @@ describe('execution evidence', () => { resolve({ data: { items: [row('old')], nextCursor: null } }); await flush() expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new') }) + it('refreshes the selected detail lazily and displays a changed artifact as stale', async () => { + vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [row('artifact')], nextCursor: null } } as never) + vi.mocked(executionEvidenceApi.get).mockResolvedValue({ data: { ...row('artifact'), validity: 'STALE' } } as never) + const { host } = mount(); host.querySelector('[data-evidence-toggle]')!.click(); await flush() + expect(executionEvidenceApi.get).not.toHaveBeenCalled() + const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush() + expect(executionEvidenceApi.get).toHaveBeenCalledWith('artifact') + expect(host.textContent).toContain(en.executionEvidence.validity.STALE) + }) + it('drops a detail response after the conversation changes', async () => { + let resolve!: (value: unknown) => void + vi.mocked(executionEvidenceApi.list).mockResolvedValueOnce({ data: { items: [row('old')], nextCursor: null } } as never) + .mockResolvedValueOnce({ data: { items: [row('new')], nextCursor: null } } as never) + vi.mocked(executionEvidenceApi.get).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never) + const { host, props } = mount(); host.querySelector('[data-evidence-toggle]')!.click(); await flush() + const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush() + props.conversationId = 'two'; await flush() + resolve({ data: { ...row('old'), summary: 'stale sensitive detail' } }); await flush() + expect(host.textContent).not.toContain('stale sensitive detail') + expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new') + }) + it('removes the old row when detail authorization is revoked', async () => { + vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [row('old')], nextCursor: null } } as never) + vi.mocked(executionEvidenceApi.get).mockRejectedValue({ code: 404 }) + const { host } = mount(); host.querySelector('[data-evidence-toggle]')!.click(); await flush() + const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush() + expect(host.querySelectorAll('[data-evidence-item]')).toHaveLength(0) + expect(host.querySelector('[role="alert"]')?.textContent).toContain('permission') + }) + })