From 91a6a96a2a3caece9a722846a0aea829b4b3497c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:16:00 +0000 Subject: [PATCH] feat(attachments): show test attachments on the execution Automated tests can now attach logs, HAR files, videos, Playwright traces, HTML snapshots and images to their results (AnglesHQ/angles, POST /build/:buildId/attachment). This shows them where the test results are: - An "Attachments" row of chips on an expanded execution, and on any step that has its own (e.g. the page HTML at the failing step). The execution header also counts them. Metadata is fetched once, the first time the execution is expanded. - Clicking a chip opens a viewer suited to the file: - logs and JSON: numbered lines with a "show only lines containing" filter - HAR: a table of requests with failed ones (4xx/5xx and no response) highlighted, and a "failed requests only" toggle - video: a player - HTML snapshots: the page in an empty-sandbox iframe, so its scripts never run inside Angles - images: the picture - traces and archives: download, with a pointer to trace.playwright.dev - Every viewer has a Download button that keeps the original file name. Files are fetched with the session (axios + blob), as the existing manual attachments are, so this works when the API and UI are on different origins. Strings are in all five catalogues; colours come from tokens, with one new token for the HTML snapshot frame's background. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgQXSUjuLVXmLWxobnMfSf --- .../execution-timeline/StepsTimeline.js | 9 + .../test-attachments/AttachmentList.js | 74 ++++++ .../test-attachments/AttachmentViewer.js | 194 +++++++++++++++ .../features/test-attachments/HarViewer.js | 131 ++++++++++ .../features/test-attachments/KindIcon.js | 31 +++ .../features/test-attachments/TextViewer.js | 77 ++++++ .../test-attachments/attachmentFormat.js | 31 +++ .../features/test-attachments/styles.less | 233 ++++++++++++++++++ .../useExecutionAttachments.js | 58 +++++ .../features/test-suite/ActionComponent.js | 2 + .../features/test-suite/ExecutionTable.js | 21 ++ src/styles/index.less | 1 + src/styles/tokens/_color.less | 7 + src/translations/cn.json | 36 +++ src/translations/en.json | 36 +++ src/translations/hi.json | 36 +++ src/translations/nl.json | 36 +++ src/translations/th.json | 36 +++ 18 files changed, 1049 insertions(+) create mode 100644 src/components/features/test-attachments/AttachmentList.js create mode 100644 src/components/features/test-attachments/AttachmentViewer.js create mode 100644 src/components/features/test-attachments/HarViewer.js create mode 100644 src/components/features/test-attachments/KindIcon.js create mode 100644 src/components/features/test-attachments/TextViewer.js create mode 100644 src/components/features/test-attachments/attachmentFormat.js create mode 100644 src/components/features/test-attachments/styles.less create mode 100644 src/components/features/test-attachments/useExecutionAttachments.js diff --git a/src/components/features/execution-timeline/StepsTimeline.js b/src/components/features/execution-timeline/StepsTimeline.js index ff9b4fb..1067f9e 100644 --- a/src/components/features/execution-timeline/StepsTimeline.js +++ b/src/components/features/execution-timeline/StepsTimeline.js @@ -12,6 +12,7 @@ import InfoRoundIcon from '@rsuite/icons/InfoRound'; import WarningRoundIcon from '@rsuite/icons/WarningRound'; import CheckRoundIcon from '@rsuite/icons/CheckRound'; import RemindRoundIcon from '@rsuite/icons/RemindRound'; +import AttachmentList from '../test-attachments/AttachmentList'; const StepsTimeline = function (props) { const { @@ -19,6 +20,7 @@ const StepsTimeline = function (props) { openModal, screenshots, showScreenshots = true, + attachments, } = props; const getScreenShot = (screenshotId) => { @@ -130,6 +132,13 @@ const StepsTimeline = function (props) {
{renderStepBody(step)} + { attachments ? ( + + ) : null }
{renderScreenshot(step)}
diff --git a/src/components/features/test-attachments/AttachmentList.js b/src/components/features/test-attachments/AttachmentList.js new file mode 100644 index 0000000..42e126a --- /dev/null +++ b/src/components/features/test-attachments/AttachmentList.js @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import AttachmentViewer from './AttachmentViewer'; +import KindIcon from './KindIcon'; +import { formatBytes } from './attachmentFormat'; + +/* + * A row of chips, one per attachment, each opening the attachment in a viewer suited to + * its kind. `ids` are the ids the execution (or step) lists; `byId` is the metadata loaded + * by useExecutionAttachments. Ids with no metadata - a manual-testing image on a manual + * step, or an attachment since deleted - are skipped. + */ +const AttachmentList = function ({ + ids, byId, status, showLabel, +}) { + const intl = useIntl(); + const [open, setOpen] = useState(undefined); + + if (!ids || ids.length === 0) return null; + if (status === 'loading') { + return ( +
+ + + +
+ ); + } + if (status === 'error') { + return ( +
+ + + +
+ ); + } + const attachments = ids.map((id) => byId[id]).filter(Boolean); + if (attachments.length === 0) return null; + + return ( +
+ { showLabel ? ( + + + + ) : null } + { attachments.map((attachment) => ( + + ))} + { open ? ( + setOpen(undefined)} /> + ) : null } +
+ ); +}; + +export default AttachmentList; diff --git a/src/components/features/test-attachments/AttachmentViewer.js b/src/components/features/test-attachments/AttachmentViewer.js new file mode 100644 index 0000000..f1b8816 --- /dev/null +++ b/src/components/features/test-attachments/AttachmentViewer.js @@ -0,0 +1,194 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Modal, Button, ButtonToolbar, Loader, +} from 'rsuite'; +import { MdOutlineFileDownload } from 'react-icons/md'; +import TextViewer from './TextViewer'; +import HarViewer from './HarViewer'; +import KindIcon from './KindIcon'; +import { formatBytes } from './attachmentFormat'; + +// Kinds read as text before rendering; everything else is shown from a blob URL or only +// offered for download. +const TEXT_KINDS = ['log', 'json', 'har', 'html']; +const PREVIEWABLE_KINDS = [...TEXT_KINDS, 'video', 'image']; + +// Hands the browser a blob to save under the attachment's own name. The file endpoint +// needs the session cookie, so a plain link would come back 401 in any deployment where +// the API and the UI are on different origins. +const saveBlob = (blob, name) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name || 'attachment'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); +}; + +const AttachmentBody = function ({ attachment, blob, objectUrl }) { + const [text, setText] = useState(undefined); + + useEffect(() => { + let cancelled = false; + if (blob && TEXT_KINDS.includes(attachment.kind)) { + blob.text().then((value) => { if (!cancelled) setText(value); }); + } + return () => { cancelled = true; }; + }, [blob, attachment.kind]); + + switch (attachment.kind) { + case 'video': + return ( + // eslint-disable-next-line jsx-a11y/media-has-caption +