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) => (
+
{
+ event.stopPropagation();
+ setOpen(attachment);
+ }}
+ >
+
+ {attachment.originalName}
+ {formatBytes(intl, attachment.size)}
+
+ ))}
+ { 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
+
+ );
+ case 'image':
+ return ;
+ case 'log':
+ case 'json':
+ return text === undefined ? : ;
+ case 'har':
+ return text === undefined ? : ;
+ case 'html':
+ return text === undefined ? : (
+ <>
+
+
+
+ {/* An empty sandbox: no scripts, no forms, no same-origin access. The snapshot is
+ the page under test, so its scripts must never run inside Angles. */}
+
+ >
+ );
+ case 'trace':
+ return (
+
+
+ trace.playwright.dev
+
+ ),
+ }}
+ />
+
+ );
+ default:
+ return (
+
+
+
+ );
+ }
+};
+
+/*
+ * Shows one attachment in a modal, with a viewer suited to its kind: text with a line
+ * filter for logs and JSON, a request table for HAR files, a player for video, the page
+ * in a script-less sandbox for HTML snapshots, and the picture for images. Traces and
+ * archives are offered for download.
+ */
+const AttachmentViewer = function ({ attachment, onClose }) {
+ const intl = useIntl();
+ const [state, setState] = useState({ status: 'loading' });
+ const previewable = PREVIEWABLE_KINDS.includes(attachment.kind);
+
+ useEffect(() => {
+ if (!previewable) {
+ setState({ status: 'ready' });
+ return undefined;
+ }
+ let cancelled = false;
+ let created;
+ setState({ status: 'loading' });
+ axios.get(`attachment/${attachment._id}/file`, { responseType: 'blob' })
+ .then((response) => {
+ if (cancelled) return;
+ created = URL.createObjectURL(response.data);
+ setState({ status: 'ready', blob: response.data, objectUrl: created });
+ })
+ .catch(() => { if (!cancelled) setState({ status: 'error' }); });
+ return () => {
+ cancelled = true;
+ if (created) URL.revokeObjectURL(created);
+ };
+ }, [attachment._id, previewable]);
+
+ const download = async () => {
+ try {
+ const blob = state.blob || (await axios.get(
+ `attachment/${attachment._id}/file`,
+ { params: { download: true }, responseType: 'blob' },
+ )).data;
+ saveBlob(blob, attachment.originalName);
+ } catch (error) {
+ setState((current) => ({ ...current, downloadFailed: true }));
+ }
+ };
+
+ return (
+ event.stopPropagation()}
+ >
+
+
+
+ {attachment.originalName}
+
+
+ {' · '}
+ {formatBytes(intl, attachment.size)}
+
+
+
+
+ { state.status === 'loading' ? (
+
+
+
+ ) : null }
+ { state.status === 'error' ? (
+
+
+
+ ) : null }
+ { state.status === 'ready' ? (
+
+ ) : null }
+ { state.downloadFailed ? (
+
+
+
+ ) : null }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AttachmentViewer;
diff --git a/src/components/features/test-attachments/HarViewer.js b/src/components/features/test-attachments/HarViewer.js
new file mode 100644
index 0000000..1d39bcd
--- /dev/null
+++ b/src/components/features/test-attachments/HarViewer.js
@@ -0,0 +1,131 @@
+import React, { useMemo, useState } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { Toggle } from 'rsuite';
+import { formatBytes, formatMilliseconds } from './attachmentFormat';
+
+// More rows than this make the table sluggish; a HAR for one test is usually far smaller.
+const MAX_ROWS = 2000;
+
+// A request failed if the server answered with an error, or never answered at all.
+// Browsers record a request that got no response (blocked, aborted, a network error) as
+// status 0, and Playwright as -1.
+const isFailed = (status) => status <= 0 || status >= 400;
+
+const statusClass = (status) => {
+ if (isFailed(status)) return 'status-fail';
+ if (status >= 300) return 'status-info';
+ return 'status-pass';
+};
+
+const parseHar = (text) => {
+ try {
+ const har = JSON.parse(text);
+ if (!har || !har.log || !Array.isArray(har.log.entries)) return undefined;
+ return har.log.entries.map((entry, index) => {
+ const request = entry.request || {};
+ const response = entry.response || {};
+ const content = response.content || {};
+ const size = content.size >= 0 ? content.size : response.bodySize;
+ return {
+ key: index,
+ method: request.method || '',
+ url: request.url || '',
+ status: Number(response.status) || 0,
+ type: (content.mimeType || '').split(';')[0],
+ size: size >= 0 ? size : undefined,
+ time: entry.time,
+ };
+ });
+ } catch (error) {
+ return undefined;
+ }
+};
+
+/*
+ * A HAR file as a table of requests, in the order they were made, with failed requests
+ * highlighted and a toggle to show only those - usually the reason a test was looking
+ * at the network in the first place.
+ */
+const HarViewer = function ({ text }) {
+ const intl = useIntl();
+ const entries = useMemo(() => parseHar(text), [text]);
+ const [failedOnly, setFailedOnly] = useState(false);
+
+ if (!entries) {
+ return (
+
+
+
+ );
+ }
+ const failedCount = entries.filter((entry) => isFailed(entry.status)).length;
+ const rows = (failedOnly ? entries.filter((entry) => isFailed(entry.status)) : entries)
+ .slice(0, MAX_ROWS);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ { rows.length === 0 ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { rows.map((entry) => (
+
+
+ { entry.status > 0 ? entry.status : (
+
+ )}
+
+ {entry.method}
+ {entry.url}
+ {entry.type}
+ {formatBytes(intl, entry.size)}
+ {formatMilliseconds(intl, entry.time)}
+
+ ))}
+
+
+
+ )}
+ { (failedOnly ? failedCount : entries.length) > MAX_ROWS ? (
+
+
+
+ ) : null }
+
+ );
+};
+
+export default HarViewer;
diff --git a/src/components/features/test-attachments/KindIcon.js b/src/components/features/test-attachments/KindIcon.js
new file mode 100644
index 0000000..40a7eed
--- /dev/null
+++ b/src/components/features/test-attachments/KindIcon.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import {
+ MdOutlineArticle,
+ MdDataObject,
+ MdOutlineLan,
+ MdOutlineVideocam,
+ MdOutlineTimeline,
+ MdOutlineFolderZip,
+ MdOutlineWeb,
+ MdOutlineImage,
+ MdOutlineInsertDriveFile,
+} from 'react-icons/md';
+
+const KIND_ICONS = {
+ log: MdOutlineArticle,
+ json: MdDataObject,
+ har: MdOutlineLan,
+ video: MdOutlineVideocam,
+ trace: MdOutlineTimeline,
+ archive: MdOutlineFolderZip,
+ html: MdOutlineWeb,
+ image: MdOutlineImage,
+};
+
+// The icon for an attachment kind, coloured by its container.
+const KindIcon = function ({ kind }) {
+ const Icon = KIND_ICONS[kind] || MdOutlineInsertDriveFile;
+ return ;
+};
+
+export default KindIcon;
diff --git a/src/components/features/test-attachments/TextViewer.js b/src/components/features/test-attachments/TextViewer.js
new file mode 100644
index 0000000..71658d7
--- /dev/null
+++ b/src/components/features/test-attachments/TextViewer.js
@@ -0,0 +1,77 @@
+import React, { useMemo, useState } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { Input, InputGroup } from 'rsuite';
+import SearchIcon from '@rsuite/icons/Search';
+
+// Rendering more lines than this makes the modal sluggish; the rest is a download away.
+const MAX_LINES = 5000;
+
+const prettyJson = (text) => {
+ try {
+ return JSON.stringify(JSON.parse(text), null, 2);
+ } catch (error) {
+ return text;
+ }
+};
+
+/*
+ * A log or JSON file as numbered lines, with a filter that keeps only the lines
+ * containing the search text (case-insensitive). Line numbers stay those of the file, so a
+ * filtered view still says where each line is.
+ */
+const TextViewer = function ({ text, json }) {
+ const intl = useIntl();
+ const [filter, setFilter] = useState('');
+ const lines = useMemo(() => (json ? prettyJson(text) : text).split(/\r?\n/), [text, json]);
+ const term = filter.trim().toLowerCase();
+
+ const matching = useMemo(() => lines
+ .map((line, index) => ({ line, number: index + 1 }))
+ .filter(({ line }) => !term || line.toLowerCase().includes(term)), [lines, term]);
+ const shown = matching.slice(0, MAX_LINES);
+
+ return (
+
+
+
+
+
+ { term ? (
+
+
+
+ ) : null }
+ { shown.length === 0 ? (
+
+
+
+ ) : (
+
+ { shown.map(({ line, number }) => (
+
+ {number}
+ {line}
+
+ ))}
+
+ )}
+ { matching.length > MAX_LINES ? (
+
+
+
+ ) : null }
+
+ );
+};
+
+export default TextViewer;
diff --git a/src/components/features/test-attachments/attachmentFormat.js b/src/components/features/test-attachments/attachmentFormat.js
new file mode 100644
index 0000000..f611368
--- /dev/null
+++ b/src/components/features/test-attachments/attachmentFormat.js
@@ -0,0 +1,31 @@
+// Formats a byte count in the viewer's locale, e.g. "4.2 MB". Uses Intl unit formatting so
+// the unit is translated along with the number, with no message ids of its own. Starts at
+// kilobytes: Intl's short form of a plain byte count reads "12 byte" in English.
+export const formatBytes = (intl, bytes) => {
+ if (bytes === undefined || bytes === null) return '';
+ const units = ['kilobyte', 'megabyte', 'gigabyte'];
+ let value = bytes / 1024;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return intl.formatNumber(value, {
+ style: 'unit',
+ unit: units[unit],
+ unitDisplay: 'short',
+ // Two decimals below 1 kB, so a 12-byte response reads "0.01 kB" rather than "0 kB".
+ maximumFractionDigits: value < 1 ? 2 : 1,
+ });
+};
+
+// Formats a duration in milliseconds, e.g. "312 ms" or "1.4 s", in the viewer's locale.
+export const formatMilliseconds = (intl, ms) => {
+ if (ms === undefined || ms === null || ms < 0) return '';
+ if (ms < 1000) {
+ return intl.formatNumber(Math.round(ms), { style: 'unit', unit: 'millisecond', unitDisplay: 'short' });
+ }
+ return intl.formatNumber(ms / 1000, {
+ style: 'unit', unit: 'second', unitDisplay: 'short', maximumFractionDigits: 1,
+ });
+};
diff --git a/src/components/features/test-attachments/styles.less b/src/components/features/test-attachments/styles.less
new file mode 100644
index 0000000..df082ba
--- /dev/null
+++ b/src/components/features/test-attachments/styles.less
@@ -0,0 +1,233 @@
+// Attachments uploaded by automated tests: a chip row on the execution (and on a step),
+// and a modal viewer per kind. The chips sit inside the execution's actions row and the
+// step timeline, so they take the sub-panel tones of those surfaces.
+
+// ── Chip row ─────────────────────────────────────────────────────────────────
+.test-attachment-list {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ margin: var(--space-1) 0 var(--space-2);
+}
+
+.test-attachment-list-label {
+ color: var(--sub-panel-secondary-font-color);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.test-attachment-list-note {
+ color: var(--sub-panel-secondary-font-color);
+ font-size: var(--font-size-sm);
+}
+
+.test-attachment-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ max-width: 100%;
+ padding: var(--space-1) var(--space-3);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-pill);
+ background-color: var(--surface-raised);
+ color: var(--sub-panel-font-color);
+ font: inherit;
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ transition: border-color var(--transition-fast), background-color var(--transition-fast);
+
+ &:hover {
+ border-color: var(--color-primary);
+ }
+
+ &:focus-visible {
+ .focus-ring();
+ }
+}
+
+.test-attachment-kind-icon {
+ flex: 0 0 auto;
+ font-size: 1.15em;
+ color: var(--sub-panel-icon-color);
+}
+
+.test-attachment-chip-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.test-attachment-chip-size {
+ flex: 0 0 auto;
+ color: var(--sub-panel-secondary-font-color);
+ font-size: var(--font-size-xs);
+ font-variant-numeric: tabular-nums;
+}
+
+// ── Viewer modal ─────────────────────────────────────────────────────────────
+.test-attachment-modal {
+ .test-attachment-modal-title {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ font-weight: var(--font-weight-bold);
+ }
+
+ .test-attachment-kind-icon {
+ color: var(--icon-action-color);
+ }
+
+ .test-attachment-modal-name {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ }
+
+ .test-attachment-modal-meta {
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-regular);
+ }
+
+ .test-attachment-modal-actions {
+ display: flex;
+ gap: var(--space-2);
+ justify-content: flex-end;
+
+ .rs-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ }
+ }
+}
+
+.test-attachment-video,
+.test-attachment-image {
+ display: block;
+ max-width: 100%;
+ max-height: 70vh;
+ margin: 0 auto;
+ border-radius: var(--radius-md);
+ background-color: var(--surface-sunken);
+}
+
+.test-attachment-html {
+ display: block;
+ width: 100%;
+ height: 65vh;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background-color: var(--html-snapshot-background);
+}
+
+// ── Text (log / JSON) ────────────────────────────────────────────────────────
+.test-attachment-text-filter {
+ margin-bottom: var(--space-2);
+}
+
+.test-attachment-text-lines {
+ max-height: 60vh;
+ margin: 0;
+ padding: var(--space-2) 0;
+ overflow: auto;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ background-color: var(--surface-sunken);
+ color: var(--modal-font-color);
+ font-size: var(--font-size-xs);
+ line-height: var(--line-height-normal);
+}
+
+.test-attachment-text-line {
+ display: flex;
+ gap: var(--space-3);
+ padding: 0 var(--space-3);
+ white-space: pre;
+
+ code {
+ background: none;
+ padding: 0;
+ color: inherit;
+ }
+}
+
+.test-attachment-text-number {
+ flex: 0 0 3.5em;
+ color: var(--modal-secondary-font-color);
+ text-align: right;
+ user-select: none;
+}
+
+// ── HAR ──────────────────────────────────────────────────────────────────────
+.test-attachment-har-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
+
+ .page-help-text {
+ margin: 0;
+ }
+}
+
+.test-attachment-har-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--modal-font-color);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+}
+
+.test-attachment-har-scroll {
+ max-height: 60vh;
+ overflow: auto;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+}
+
+.test-attachment-har-table {
+ width: 100%;
+ border-collapse: collapse;
+ color: var(--modal-font-color);
+ font-size: var(--font-size-xs);
+
+ th {
+ position: sticky;
+ top: 0;
+ padding: var(--space-2);
+ background-color: var(--sub-table-header-background);
+ color: var(--sub-table-header-font-color);
+ font-weight: var(--font-weight-semibold);
+ text-align: left;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: var(--space-1) var(--space-2);
+ border-top: 1px solid var(--border-subtle);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ }
+}
+
+.test-attachment-har-table .test-attachment-har-number {
+ text-align: right;
+}
+
+.test-attachment-har-url {
+ max-width: 32rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.test-attachment-har-failed td {
+ background-color: color-mix(in srgb, var(--fail-color) 8%, transparent);
+}
diff --git a/src/components/features/test-attachments/useExecutionAttachments.js b/src/components/features/test-attachments/useExecutionAttachments.js
new file mode 100644
index 0000000..36a1c9a
--- /dev/null
+++ b/src/components/features/test-attachments/useExecutionAttachments.js
@@ -0,0 +1,58 @@
+import { useEffect, useRef, useState } from 'react';
+import axios from 'axios';
+
+// Whether an execution references any attachment, at execution level or on a step. Steps
+// of a manual execution can carry manual-testing attachments too; those are not returned
+// by the listing below and are simply not shown here.
+export const hasAttachmentReferences = (execution) => (
+ (execution.attachments || []).length > 0
+ || (execution.actions || []).some((action) => (action.steps || [])
+ .some((step) => (step.attachments || []).length > 0))
+);
+
+/*
+ * Loads the metadata (kind, name, size) of the files an automated execution uploaded.
+ *
+ * The execution only carries attachment ids, so the metadata is fetched once, the first
+ * time the execution is expanded, rather than for every execution on the page. Returns
+ * { status: 'idle' | 'loading' | 'loaded' | 'error', byId }.
+ */
+const useExecutionAttachments = (execution, enabled) => {
+ const [state, setState] = useState({ status: 'idle', byId: {} });
+ // Which execution has been requested. A ref rather than state, so starting the request
+ // does not re-run the effect (whose clean-up would discard the response).
+ const requested = useRef(undefined);
+ const wanted = enabled && hasAttachmentReferences(execution);
+
+ useEffect(() => {
+ if (!wanted || requested.current === execution._id) return undefined;
+ requested.current = execution._id;
+ let cancelled = false;
+ let finished = false;
+ setState({ status: 'loading', byId: {} });
+ axios.get('attachment', { params: { executionId: execution._id } })
+ .then((response) => {
+ finished = true;
+ if (cancelled) return;
+ const byId = {};
+ (response.data.attachments || []).forEach((attachment) => {
+ byId[attachment._id] = attachment;
+ });
+ setState({ status: 'loaded', byId });
+ })
+ .catch(() => {
+ finished = true;
+ if (!cancelled) setState({ status: 'error', byId: {} });
+ });
+ return () => {
+ cancelled = true;
+ // Collapsed (or unmounted) before the response arrived: request again next time.
+ // Once it has arrived, the result is kept and collapsing does not refetch.
+ if (!finished) requested.current = undefined;
+ };
+ }, [wanted, execution._id]);
+
+ return state;
+};
+
+export default useExecutionAttachments;
diff --git a/src/components/features/test-suite/ActionComponent.js b/src/components/features/test-suite/ActionComponent.js
index c935ce6..7aef1c9 100644
--- a/src/components/features/test-suite/ActionComponent.js
+++ b/src/components/features/test-suite/ActionComponent.js
@@ -14,6 +14,7 @@ const ActionComponent = function (props) {
actionIndex,
execution,
showScreenshots,
+ attachments,
} = props;
const expanded = isActionExpanded(execution._id, actionIndex);
@@ -63,6 +64,7 @@ const ActionComponent = function (props) {
screenshots={screenshots}
openModal={openModal}
showScreenshots={showScreenshots}
+ attachments={attachments}
/>
) : null }
diff --git a/src/components/features/test-suite/ExecutionTable.js b/src/components/features/test-suite/ExecutionTable.js
index 5edf796..a9fe329 100644
--- a/src/components/features/test-suite/ExecutionTable.js
+++ b/src/components/features/test-suite/ExecutionTable.js
@@ -11,6 +11,8 @@ import Moment from 'react-moment';
import ActionComponent from './ActionComponent';
import ExecutionStateContext from '../../../context/ExecutionStateContext';
import { getDuration } from '../../../utility/TimeUtilities';
+import AttachmentList from '../test-attachments/AttachmentList';
+import useExecutionAttachments from '../test-attachments/useExecutionAttachments';
const ExecutionTable = function (props) {
const { isExecutionExpanded, toggleExecution } = useContext(ExecutionStateContext);
@@ -40,6 +42,10 @@ const ExecutionTable = function (props) {
const status = execution.status.toLowerCase();
const expanded = isExecutionExpanded(execution._id);
const actionCount = execution.actions ? execution.actions.length : 0;
+ const attachmentCount = (execution.attachments || []).length;
+ // Fetched the first time the execution is expanded; shared by the execution-level list
+ // below and the step-level lists inside its actions.
+ const attachments = useExecutionAttachments(execution, expanded);
return (
@@ -99,6 +105,14 @@ const ExecutionTable = function (props) {
/>
) : null }
+ { attachmentCount > 0 ? (
+
+
+
+ ) : null }
{ showHistoryLink !== false ? (
@@ -119,6 +133,12 @@ const ExecutionTable = function (props) {
{ expanded ? (
+
{ execution.actions.map((action, actionIndex) => [
,
])}
diff --git a/src/styles/index.less b/src/styles/index.less
index a34387e..0ac1e18 100644
--- a/src/styles/index.less
+++ b/src/styles/index.less
@@ -58,3 +58,4 @@
@import '../components/features/version-badge/styles.less';
@import '../components/features/version-diff/styles.less';
@import '../components/features/test-suite/styles.less';
+@import '../components/features/test-attachments/styles.less';
diff --git a/src/styles/tokens/_color.less b/src/styles/tokens/_color.less
index 59b4fe6..86af1bf 100644
--- a/src/styles/tokens/_color.less
+++ b/src/styles/tokens/_color.less
@@ -502,6 +502,13 @@
--overlay-suggestion-color: #ff9800;
--overlay-marker-font-color: #ffffff;
+ // ── HTML snapshot frame ─────────────────────────────────────────────────
+ // Behind a page snapshot an automated test attached. The snapshot brings its own
+ // colours and was rendered on the browser's default white, so a page that sets no
+ // background of its own must still get white rather than the theme's surface.
+ // Like the overlay markers above it does not change between themes.
+ --html-snapshot-background: #ffffff;
+
// ── Semantic: brand / interaction ───────────────────────────────────────
--color-primary: var(--master-color-3);
--color-primary-hover: var(--master-color-3-relative);
diff --git a/src/translations/cn.json b/src/translations/cn.json
index f612bea..f1384d5 100644
--- a/src/translations/cn.json
+++ b/src/translations/cn.json
@@ -222,6 +222,42 @@
"common.component.suite-table.header.execution-count": "{count, plural, other {# 個測試}}",
"common.component.suite-table.execution.action-count": "{count, plural, other {# 個動作}}",
"common.component.suite-table.action.step-count": "{count, plural, other {# 個步驟}}",
+ "common.component.suite-table.execution.attachment-count": "{count, plural, other {# 個附件}}",
+ "common.component.test-attachments.title": "附件",
+ "common.component.test-attachments.list-loading": "正在載入附件…",
+ "common.component.test-attachments.list-error": "無法載入附件。",
+ "common.component.test-attachments.open": "開啟 {name}",
+ "common.component.test-attachments.kind.log": "日誌",
+ "common.component.test-attachments.kind.json": "JSON 資料",
+ "common.component.test-attachments.kind.har": "網路記錄 (HAR)",
+ "common.component.test-attachments.kind.video": "影片",
+ "common.component.test-attachments.kind.trace": "Playwright 追蹤",
+ "common.component.test-attachments.kind.archive": "壓縮檔",
+ "common.component.test-attachments.kind.html": "HTML 快照",
+ "common.component.test-attachments.kind.image": "圖片",
+ "common.component.test-attachments.loading": "正在載入附件…",
+ "common.component.test-attachments.load-error": "無法載入此附件。",
+ "common.component.test-attachments.download": "下載",
+ "common.component.test-attachments.close": "關閉",
+ "common.component.test-attachments.html.sandbox-note": "已停用指令碼顯示。下載檔案即可在瀏覽器中開啟。",
+ "common.component.test-attachments.trace.hint": "無法在此預覽追蹤檔。請下載追蹤檔並拖放到 {link},即可逐步檢視測試。",
+ "common.component.test-attachments.no-preview": "無法預覽此檔案。請下載後開啟。",
+ "common.component.test-attachments.text.filter": "只顯示包含以下內容的行…",
+ "common.component.test-attachments.text.match-count": "{total} 行中有 {count, plural, other {# 行}}符合",
+ "common.component.test-attachments.text.no-matches": "沒有符合的行。",
+ "common.component.test-attachments.text.truncated": "僅顯示前 {count} 行。下載檔案以檢視其餘內容。",
+ "common.component.test-attachments.har.invalid": "此檔案不是有效的 HAR 檔案。",
+ "common.component.test-attachments.har.summary": "{count, plural, other {# 個請求}},{failed} 個失敗",
+ "common.component.test-attachments.har.failed-only": "只顯示失敗的請求",
+ "common.component.test-attachments.har.empty": "沒有可顯示的請求。",
+ "common.component.test-attachments.har.no-response": "無回應",
+ "common.component.test-attachments.har.truncated": "僅顯示前 {count} 個請求。下載檔案以檢視其餘內容。",
+ "common.component.test-attachments.har.header.status": "狀態",
+ "common.component.test-attachments.har.header.method": "方法",
+ "common.component.test-attachments.har.header.url": "請求 URL",
+ "common.component.test-attachments.har.header.type": "類型",
+ "common.component.test-attachments.har.header.size": "大小",
+ "common.component.test-attachments.har.header.time": "時間",
"common.component.screenshot-view.header": "螢幕截圖檢視器",
"common.component.screenshot-view.tabs.enable-alert": "要啟用「歷史記錄」和「與基線比較」選項卡,請在上傳螢幕截圖時提供視圖和平台詳細資訊",
"common.component.screenshot-view.tabs.label.image": "螢幕截圖",
diff --git a/src/translations/en.json b/src/translations/en.json
index 846096c..c83be5f 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -222,6 +222,42 @@
"common.component.suite-table.header.execution-count": "{count, plural, one {# test} other {# tests}}",
"common.component.suite-table.execution.action-count": "{count, plural, one {# action} other {# actions}}",
"common.component.suite-table.action.step-count": "{count, plural, one {# step} other {# steps}}",
+ "common.component.suite-table.execution.attachment-count": "{count, plural, one {# attachment} other {# attachments}}",
+ "common.component.test-attachments.title": "Attachments",
+ "common.component.test-attachments.list-loading": "Loading attachments…",
+ "common.component.test-attachments.list-error": "The attachments could not be loaded.",
+ "common.component.test-attachments.open": "Open {name}",
+ "common.component.test-attachments.kind.log": "Log",
+ "common.component.test-attachments.kind.json": "JSON data",
+ "common.component.test-attachments.kind.har": "Network log (HAR)",
+ "common.component.test-attachments.kind.video": "Video",
+ "common.component.test-attachments.kind.trace": "Playwright trace",
+ "common.component.test-attachments.kind.archive": "Archive",
+ "common.component.test-attachments.kind.html": "HTML snapshot",
+ "common.component.test-attachments.kind.image": "Image",
+ "common.component.test-attachments.loading": "Loading attachment…",
+ "common.component.test-attachments.load-error": "This attachment could not be loaded.",
+ "common.component.test-attachments.download": "Download",
+ "common.component.test-attachments.close": "Close",
+ "common.component.test-attachments.html.sandbox-note": "Shown with scripts disabled. Download the file to open it in a browser.",
+ "common.component.test-attachments.trace.hint": "Traces can't be previewed here. Download the trace and drop it onto {link} to step through the test.",
+ "common.component.test-attachments.no-preview": "This file can't be previewed. Download it to open it.",
+ "common.component.test-attachments.text.filter": "Show only lines containing…",
+ "common.component.test-attachments.text.match-count": "{count, plural, one {# line} other {# lines}} of {total} match",
+ "common.component.test-attachments.text.no-matches": "No lines match.",
+ "common.component.test-attachments.text.truncated": "Showing the first {count} lines. Download the file to see the rest.",
+ "common.component.test-attachments.har.invalid": "This file is not a valid HAR file.",
+ "common.component.test-attachments.har.summary": "{count, plural, one {# request} other {# requests}}, {failed} failed",
+ "common.component.test-attachments.har.failed-only": "Failed requests only",
+ "common.component.test-attachments.har.empty": "No requests to show.",
+ "common.component.test-attachments.har.no-response": "No response",
+ "common.component.test-attachments.har.truncated": "Showing the first {count} requests. Download the file to see the rest.",
+ "common.component.test-attachments.har.header.status": "Status",
+ "common.component.test-attachments.har.header.method": "Method",
+ "common.component.test-attachments.har.header.url": "Request URL",
+ "common.component.test-attachments.har.header.type": "Type",
+ "common.component.test-attachments.har.header.size": "Size",
+ "common.component.test-attachments.har.header.time": "Time",
"common.component.screenshot-view.header": "Screenshot Viewer",
"common.component.screenshot-view.tabs.enable-alert": "To enable the \"History\" and \"Compare with Baseline\" tabs please provide a view and platform details when uploading the screenshots",
"common.component.screenshot-view.tabs.label.image": "Screenshot",
diff --git a/src/translations/hi.json b/src/translations/hi.json
index 0465c76..93d7e84 100644
--- a/src/translations/hi.json
+++ b/src/translations/hi.json
@@ -222,6 +222,42 @@
"common.component.suite-table.header.execution-count": "{count, plural, one {# टेस्ट} other {# टेस्ट}}",
"common.component.suite-table.execution.action-count": "{count, plural, one {# क्रिया} other {# क्रियाएँ}}",
"common.component.suite-table.action.step-count": "{count, plural, one {# चरण} other {# चरण}}",
+ "common.component.suite-table.execution.attachment-count": "{count, plural, one {# अनुलग्नक} other {# अनुलग्नक}}",
+ "common.component.test-attachments.title": "अनुलग्नक",
+ "common.component.test-attachments.list-loading": "अनुलग्नक लोड हो रहे हैं…",
+ "common.component.test-attachments.list-error": "अनुलग्नक लोड नहीं किए जा सके।",
+ "common.component.test-attachments.open": "{name} खोलें",
+ "common.component.test-attachments.kind.log": "लॉग",
+ "common.component.test-attachments.kind.json": "JSON डेटा",
+ "common.component.test-attachments.kind.har": "नेटवर्क लॉग (HAR)",
+ "common.component.test-attachments.kind.video": "वीडियो",
+ "common.component.test-attachments.kind.trace": "Playwright ट्रेस",
+ "common.component.test-attachments.kind.archive": "संग्रह",
+ "common.component.test-attachments.kind.html": "HTML स्नैपशॉट",
+ "common.component.test-attachments.kind.image": "छवि",
+ "common.component.test-attachments.loading": "अनुलग्नक लोड हो रहा है…",
+ "common.component.test-attachments.load-error": "यह अनुलग्नक लोड नहीं किया जा सका।",
+ "common.component.test-attachments.download": "डाउनलोड करें",
+ "common.component.test-attachments.close": "बंद करें",
+ "common.component.test-attachments.html.sandbox-note": "स्क्रिप्ट बंद करके दिखाया गया है। ब्राउज़र में खोलने के लिए फ़ाइल डाउनलोड करें।",
+ "common.component.test-attachments.trace.hint": "ट्रेस का यहाँ पूर्वावलोकन नहीं हो सकता। ट्रेस डाउनलोड करें और परीक्षण को चरण-दर-चरण देखने के लिए उसे {link} पर छोड़ें।",
+ "common.component.test-attachments.no-preview": "इस फ़ाइल का पूर्वावलोकन नहीं हो सकता। खोलने के लिए इसे डाउनलोड करें।",
+ "common.component.test-attachments.text.filter": "केवल वे पंक्तियाँ दिखाएँ जिनमें हो…",
+ "common.component.test-attachments.text.match-count": "{total} में से {count, plural, one {# पंक्ति} other {# पंक्तियाँ}} मेल खाती हैं",
+ "common.component.test-attachments.text.no-matches": "कोई पंक्ति मेल नहीं खाती।",
+ "common.component.test-attachments.text.truncated": "पहली {count} पंक्तियाँ दिखाई जा रही हैं। बाकी देखने के लिए फ़ाइल डाउनलोड करें।",
+ "common.component.test-attachments.har.invalid": "यह मान्य HAR फ़ाइल नहीं है।",
+ "common.component.test-attachments.har.summary": "{count, plural, one {# अनुरोध} other {# अनुरोध}}, {failed} विफल",
+ "common.component.test-attachments.har.failed-only": "केवल विफल अनुरोध",
+ "common.component.test-attachments.har.empty": "दिखाने के लिए कोई अनुरोध नहीं।",
+ "common.component.test-attachments.har.no-response": "कोई प्रतिक्रिया नहीं",
+ "common.component.test-attachments.har.truncated": "पहले {count} अनुरोध दिखाए जा रहे हैं। बाकी देखने के लिए फ़ाइल डाउनलोड करें।",
+ "common.component.test-attachments.har.header.status": "स्थिति",
+ "common.component.test-attachments.har.header.method": "विधि",
+ "common.component.test-attachments.har.header.url": "अनुरोध URL",
+ "common.component.test-attachments.har.header.type": "प्रकार",
+ "common.component.test-attachments.har.header.size": "आकार",
+ "common.component.test-attachments.har.header.time": "समय",
"common.component.screenshot-view.header": "स्क्रीनशॉट व्यूअर",
"common.component.screenshot-view.tabs.enable-alert": "\"इतिहास\" और \"बेसलाइन से तुलना\" टैब सक्षम करने के लिए, कृपया स्क्रीनशॉट अपलोड करते समय व्यू और प्लेटफ़ॉर्म विवरण प्रदान करें",
"common.component.screenshot-view.tabs.label.image": "स्क्रीनशॉट",
diff --git a/src/translations/nl.json b/src/translations/nl.json
index f91ff78..a646239 100644
--- a/src/translations/nl.json
+++ b/src/translations/nl.json
@@ -222,6 +222,42 @@
"common.component.suite-table.header.execution-count": "{count, plural, one {# test} other {# tests}}",
"common.component.suite-table.execution.action-count": "{count, plural, one {# actie} other {# acties}}",
"common.component.suite-table.action.step-count": "{count, plural, one {# stap} other {# stappen}}",
+ "common.component.suite-table.execution.attachment-count": "{count, plural, one {# bijlage} other {# bijlagen}}",
+ "common.component.test-attachments.title": "Bijlagen",
+ "common.component.test-attachments.list-loading": "Bijlagen laden…",
+ "common.component.test-attachments.list-error": "De bijlagen konden niet worden geladen.",
+ "common.component.test-attachments.open": "{name} openen",
+ "common.component.test-attachments.kind.log": "Logbestand",
+ "common.component.test-attachments.kind.json": "JSON-gegevens",
+ "common.component.test-attachments.kind.har": "Netwerklog (HAR)",
+ "common.component.test-attachments.kind.video": "Video-opname",
+ "common.component.test-attachments.kind.trace": "Playwright-trace",
+ "common.component.test-attachments.kind.archive": "Archief",
+ "common.component.test-attachments.kind.html": "HTML-momentopname",
+ "common.component.test-attachments.kind.image": "Afbeelding",
+ "common.component.test-attachments.loading": "Bijlage laden…",
+ "common.component.test-attachments.load-error": "Deze bijlage kon niet worden geladen.",
+ "common.component.test-attachments.download": "Downloaden",
+ "common.component.test-attachments.close": "Sluiten",
+ "common.component.test-attachments.html.sandbox-note": "Weergegeven met scripts uitgeschakeld. Download het bestand om het in een browser te openen.",
+ "common.component.test-attachments.trace.hint": "Traces kunnen hier niet worden bekeken. Download de trace en sleep hem naar {link} om de test stap voor stap te doorlopen.",
+ "common.component.test-attachments.no-preview": "Dit bestand kan niet worden bekeken. Download het om het te openen.",
+ "common.component.test-attachments.text.filter": "Alleen regels tonen met…",
+ "common.component.test-attachments.text.match-count": "{count, plural, one {# regel} other {# regels}} van {total} komen overeen",
+ "common.component.test-attachments.text.no-matches": "Geen regels komen overeen.",
+ "common.component.test-attachments.text.truncated": "De eerste {count} regels worden getoond. Download het bestand om de rest te zien.",
+ "common.component.test-attachments.har.invalid": "Dit is geen geldig HAR-bestand.",
+ "common.component.test-attachments.har.summary": "{count, plural, one {# verzoek} other {# verzoeken}}, {failed} mislukt",
+ "common.component.test-attachments.har.failed-only": "Alleen mislukte verzoeken",
+ "common.component.test-attachments.har.empty": "Geen verzoeken om te tonen.",
+ "common.component.test-attachments.har.no-response": "Geen antwoord",
+ "common.component.test-attachments.har.truncated": "De eerste {count} verzoeken worden getoond. Download het bestand om de rest te zien.",
+ "common.component.test-attachments.har.header.status": "Status code",
+ "common.component.test-attachments.har.header.method": "Methode",
+ "common.component.test-attachments.har.header.url": "Verzoek-URL",
+ "common.component.test-attachments.har.header.type": "Soort",
+ "common.component.test-attachments.har.header.size": "Grootte",
+ "common.component.test-attachments.har.header.time": "Tijd",
"common.component.screenshot-view.header": "Schermafbeeldingen kijker",
"common.component.screenshot-view.tabs.enable-alert": "Om de tabbladen \"Geschiedenis\" en \"Vergelijken met de baseline\" in te schakelen, geeft U een \"Weergave\" en \"Platform details\" op bij het uploaden van de schermafbeeldingen",
"common.component.screenshot-view.tabs.label.image": "Schermafbeelding",
diff --git a/src/translations/th.json b/src/translations/th.json
index f22734a..0e7fa35 100644
--- a/src/translations/th.json
+++ b/src/translations/th.json
@@ -222,6 +222,42 @@
"common.component.suite-table.header.execution-count": "{count, plural, other {# การทดสอบ}}",
"common.component.suite-table.execution.action-count": "{count, plural, other {# การกระทำ}}",
"common.component.suite-table.action.step-count": "{count, plural, other {# ขั้นตอน}}",
+ "common.component.suite-table.execution.attachment-count": "{count, plural, other {# ไฟล์แนบ}}",
+ "common.component.test-attachments.title": "ไฟล์แนบ",
+ "common.component.test-attachments.list-loading": "กำลังโหลดไฟล์แนบ…",
+ "common.component.test-attachments.list-error": "ไม่สามารถโหลดไฟล์แนบได้",
+ "common.component.test-attachments.open": "เปิด {name}",
+ "common.component.test-attachments.kind.log": "บันทึก",
+ "common.component.test-attachments.kind.json": "ข้อมูล JSON",
+ "common.component.test-attachments.kind.har": "บันทึกเครือข่าย (HAR)",
+ "common.component.test-attachments.kind.video": "วิดีโอ",
+ "common.component.test-attachments.kind.trace": "ไฟล์ติดตามของ Playwright",
+ "common.component.test-attachments.kind.archive": "ไฟล์บีบอัด",
+ "common.component.test-attachments.kind.html": "สแนปช็อต HTML",
+ "common.component.test-attachments.kind.image": "รูปภาพ",
+ "common.component.test-attachments.loading": "กำลังโหลดไฟล์แนบ…",
+ "common.component.test-attachments.load-error": "ไม่สามารถโหลดไฟล์แนบนี้ได้",
+ "common.component.test-attachments.download": "ดาวน์โหลด",
+ "common.component.test-attachments.close": "ปิด",
+ "common.component.test-attachments.html.sandbox-note": "แสดงโดยปิดการทำงานของสคริปต์ ดาวน์โหลดไฟล์เพื่อเปิดในเบราว์เซอร์",
+ "common.component.test-attachments.trace.hint": "ไม่สามารถดูตัวอย่าง trace ที่นี่ได้ ดาวน์โหลด trace แล้วลากไปวางที่ {link} เพื่อดูการทดสอบทีละขั้นตอน",
+ "common.component.test-attachments.no-preview": "ไม่สามารถดูตัวอย่างไฟล์นี้ได้ ดาวน์โหลดเพื่อเปิดไฟล์",
+ "common.component.test-attachments.text.filter": "แสดงเฉพาะบรรทัดที่มี…",
+ "common.component.test-attachments.text.match-count": "ตรงกัน {count, plural, other {# บรรทัด}} จาก {total}",
+ "common.component.test-attachments.text.no-matches": "ไม่มีบรรทัดที่ตรงกัน",
+ "common.component.test-attachments.text.truncated": "แสดง {count} บรรทัดแรก ดาวน์โหลดไฟล์เพื่อดูส่วนที่เหลือ",
+ "common.component.test-attachments.har.invalid": "ไฟล์นี้ไม่ใช่ไฟล์ HAR ที่ถูกต้อง",
+ "common.component.test-attachments.har.summary": "{count, plural, other {# คำขอ}} ล้มเหลว {failed}",
+ "common.component.test-attachments.har.failed-only": "เฉพาะคำขอที่ล้มเหลว",
+ "common.component.test-attachments.har.empty": "ไม่มีคำขอที่จะแสดง",
+ "common.component.test-attachments.har.no-response": "ไม่มีการตอบกลับ",
+ "common.component.test-attachments.har.truncated": "แสดง {count} คำขอแรก ดาวน์โหลดไฟล์เพื่อดูส่วนที่เหลือ",
+ "common.component.test-attachments.har.header.status": "สถานะ",
+ "common.component.test-attachments.har.header.method": "เมธอด",
+ "common.component.test-attachments.har.header.url": "URL ของคำขอ",
+ "common.component.test-attachments.har.header.type": "ประเภท",
+ "common.component.test-attachments.har.header.size": "ขนาด",
+ "common.component.test-attachments.har.header.time": "เวลา",
"common.component.screenshot-view.header": "โปรแกรมดูภาพหน้าจอ",
"common.component.screenshot-view.tabs.enable-alert": "หากต้องการเปิดใช้งานแท็บ \"ประวัติ\" และ \"เปรียบเทียบกับข้อมูลพื้นฐาน\" โปรดระบุมุมมองและรายละเอียดแพลตฟอร์มเมื่ออัปโหลดภาพหน้าจอ",
"common.component.screenshot-view.tabs.label.image": "ภาพหน้าจอ",