feat(workbench/replay): polish states, severity labels, metadata layout (wave 1c)
This commit is contained in:
parent
a4a24de836
commit
ac947845c7
5 changed files with 244 additions and 26 deletions
|
|
@ -3,6 +3,7 @@ import { ReplayStatusBadge, TraceHashBadge, ReplayStatus } from "../../design/co
|
||||||
import { EmptyState } from "../../design/components/states/EmptyState";
|
import { EmptyState } from "../../design/components/states/EmptyState";
|
||||||
import { ReplayDiffViewer } from "./ReplayDiffViewer";
|
import { ReplayDiffViewer } from "./ReplayDiffViewer";
|
||||||
import { ReplayMetadataTable } from "./ReplayMetadataTable";
|
import { ReplayMetadataTable } from "./ReplayMetadataTable";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
interface ReplayComparisonPanelProps {
|
interface ReplayComparisonPanelProps {
|
||||||
artifact: ArtifactDetail;
|
artifact: ArtifactDetail;
|
||||||
|
|
@ -15,6 +16,9 @@ export function ReplayComparisonPanel({
|
||||||
comparison,
|
comparison,
|
||||||
status,
|
status,
|
||||||
}: ReplayComparisonPanelProps) {
|
}: ReplayComparisonPanelProps) {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const fromProposal = searchParams.get("fromProposal");
|
||||||
|
|
||||||
const finalComparison: ReplayComparison = comparison || {
|
const finalComparison: ReplayComparison = comparison || {
|
||||||
artifact_id: artifact.artifact_id,
|
artifact_id: artifact.artifact_id,
|
||||||
original_hash: artifact.digest,
|
original_hash: artifact.digest,
|
||||||
|
|
@ -25,6 +29,17 @@ export function ReplayComparisonPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" data-testid="replay-comparison-panel">
|
<div className="space-y-6" data-testid="replay-comparison-panel">
|
||||||
|
{fromProposal && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<Link
|
||||||
|
to={`/proposals?proposal_id=${encodeURIComponent(fromProposal)}`}
|
||||||
|
className="text-xs text-[var(--color-link)] hover:underline inline-flex items-center gap-1 font-medium"
|
||||||
|
data-testid="back-to-proposal"
|
||||||
|
>
|
||||||
|
Back to proposal #{fromProposal}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-4">
|
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-4">
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Replay Evidence</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Replay Evidence</h2>
|
||||||
<ReplayStatusBadge value={status} />
|
<ReplayStatusBadge value={status} />
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ export function ReplayDiffViewer({ divergences }: ReplayDiffViewerProps) {
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border-subtle)] pb-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border-subtle)] pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ReplayDivergenceSeverityBadge value={div.severity as ReplayDivergenceSeverity} />
|
<ReplayDivergenceSeverityBadge value={div.severity as ReplayDivergenceSeverity} />
|
||||||
|
<span
|
||||||
|
className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]"
|
||||||
|
data-testid={`severity-label-${div.severity}`}
|
||||||
|
>
|
||||||
|
{div.severity === "failure" ? "breaking" : div.severity === "warning" ? "material" : "low"}
|
||||||
|
</span>
|
||||||
<span className="font-mono text-xs text-[var(--color-text-secondary)] break-all select-all">
|
<span className="font-mono text-xs text-[var(--color-text-secondary)] break-all select-all">
|
||||||
{div.path}
|
{div.path}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,31 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
|
||||||
{ info: 0, warning: 0, failure: 0 } as Record<"info" | "warning" | "failure", number>
|
{ info: 0, warning: 0, failure: 0 } as Record<"info" | "warning" | "failure", number>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Extract run ID if present in content
|
||||||
|
let runId: string | null = null;
|
||||||
|
if (artifact.content && typeof artifact.content === "object") {
|
||||||
|
const contentObj = artifact.content as any;
|
||||||
|
runId = contentObj.run_id || contentObj.runId || contentObj.workflow_run_id || contentObj.session_id || contentObj.reasoning_trace_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract lane if present in content or parsed from path
|
||||||
|
let lane: string | null = null;
|
||||||
|
if (artifact.content && typeof artifact.content === "object") {
|
||||||
|
const contentObj = artifact.content as any;
|
||||||
|
lane = contentObj.lane || contentObj.lane_name || contentObj.laneName || contentObj.split || null;
|
||||||
|
}
|
||||||
|
if (!lane && artifact.path) {
|
||||||
|
const parts = artifact.path.split("/");
|
||||||
|
if (parts[0] === "evals" && parts.length > 1) {
|
||||||
|
lane = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamp nicely
|
||||||
|
const timestamp = artifact.created_at
|
||||||
|
? new Date(artifact.created_at).toLocaleString()
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">Metadata Audit Details</h3>
|
<h3 className="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">Metadata Audit Details</h3>
|
||||||
|
|
@ -22,8 +47,8 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--color-border-subtle)] text-[var(--color-text-muted)]">
|
<tr className="border-b border-[var(--color-border-subtle)] text-[var(--color-text-muted)]">
|
||||||
<th className="pb-2 font-medium">Property</th>
|
<th className="pb-2 font-medium w-1/3">Property</th>
|
||||||
<th className="pb-2 font-medium">Value</th>
|
<th className="pb-2 font-medium w-2/3">Value</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border-subtle)] text-[var(--color-text-secondary)]">
|
<tbody className="divide-y divide-[var(--color-border-subtle)] text-[var(--color-text-secondary)]">
|
||||||
|
|
@ -35,12 +60,32 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
|
||||||
<td className="py-2 font-medium">Kind</td>
|
<td className="py-2 font-medium">Kind</td>
|
||||||
<td className="py-2">{artifact.kind}</td>
|
<td className="py-2">{artifact.kind}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 font-medium">Content Type</td>
|
||||||
|
<td className="py-2 font-mono">{artifact.content_type}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="py-2 font-medium">Path</td>
|
<td className="py-2 font-medium">Path</td>
|
||||||
<td className="py-2 font-mono" data-testid="artifact-path-text">
|
<td className="py-2 font-mono" data-testid="artifact-path-text">
|
||||||
{artifact.path}
|
{artifact.path}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 font-medium">Created At</td>
|
||||||
|
<td className="py-2" data-testid="artifact-created-at">
|
||||||
|
{timestamp || <span className="text-[var(--color-text-muted)]">None</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 font-medium">Artifact Digest / Trace Hash</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{artifact.digest ? (
|
||||||
|
<TraceHashBadge value={artifact.digest} />
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">None</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="py-2 font-medium">Original Hash</td>
|
<td className="py-2 font-medium">Original Hash</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
|
|
@ -62,24 +107,32 @@ export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTabl
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="py-2 font-medium">Divergences</td>
|
<td className="py-2 font-medium">Run ID</td>
|
||||||
<td className="py-2">
|
<td className="py-2 font-mono" data-testid="artifact-run-id">
|
||||||
<span className="inline-flex gap-2">
|
{runId || <span className="text-[var(--color-text-muted)]">None</span>}
|
||||||
<span className="text-[var(--color-review-rejected)]">
|
|
||||||
Failure: {divergenceCounts.failure}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--color-review-pending)]">
|
|
||||||
Warning: {divergenceCounts.warning}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--color-grounding-vault)]">
|
|
||||||
Info: {divergenceCounts.info}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="py-2 font-medium">Content Type</td>
|
<td className="py-2 font-medium">Lane</td>
|
||||||
<td className="py-2 font-mono">{artifact.content_type}</td>
|
<td className="py-2" data-testid="artifact-lane">
|
||||||
|
{lane || <span className="text-[var(--color-text-muted)]">None</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 font-medium">Divergences</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<span className="inline-flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
<span className="text-[var(--color-review-rejected)] font-medium">
|
||||||
|
Failure (breaking): {divergenceCounts.failure}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-review-pending)] font-medium">
|
||||||
|
Warning (material): {divergenceCounts.warning}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-grounding-vault)] font-medium">
|
||||||
|
Info (low): {divergenceCounts.info}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,19 @@
|
||||||
|
/**
|
||||||
|
* SURVEY OF THE REPLAY THEATER COMPONENTS (src/app/replay/):
|
||||||
|
*
|
||||||
|
* 1. ArtifactList.tsx: Grouped and sorted list of available artifacts (traces, eval results,
|
||||||
|
* proposals, etc.) generated by the backend. Allows selection via `onSelect` callback.
|
||||||
|
* 2. ReplayComparisonPanel.tsx: Orchestrates the display of original and replay digests (hashes),
|
||||||
|
* the status badges, and switches between empty states (intact/equivalent, not replayed, unsupported)
|
||||||
|
* and the divergence differences viewer.
|
||||||
|
* 3. ReplayDiffViewer.tsx: Displays side-by-side original vs. replayed value comparisons for
|
||||||
|
* detected divergences using StableJsonViewer, sorted by severity (failure -> warning -> info).
|
||||||
|
* 4. ReplayMetadataTable.tsx: Standard property table detailing the selected artifact's metadata
|
||||||
|
* (artifact ID, kind, path, original hash, replay hash, divergence severity counts, content type).
|
||||||
|
* 5. ReplayRoute.tsx: The main page route coordinating state from URL search params (artifactId, fromProposal),
|
||||||
|
* querying artifacts lists/details/comparisons, and displaying appropriate loading, error, and detail panels.
|
||||||
|
*/
|
||||||
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useArtifacts, useArtifactDetail, useReplayComparison } from "../../api/queries";
|
import { useArtifacts, useArtifactDetail, useReplayComparison } from "../../api/queries";
|
||||||
import { ArtifactList } from "./ArtifactList";
|
import { ArtifactList } from "./ArtifactList";
|
||||||
|
|
@ -83,9 +99,16 @@ export function ReplayRoute() {
|
||||||
{isLoadingArtifacts ? (
|
{isLoadingArtifacts ? (
|
||||||
<LoadingState label="Comparing artifacts..." />
|
<LoadingState label="Comparing artifacts..." />
|
||||||
) : artifactsQuery.isError ? (
|
) : artifactsQuery.isError ? (
|
||||||
<div className="p-2 text-xs text-[var(--color-state-danger-text)]">
|
<ErrorState
|
||||||
Failed to load artifacts.
|
whatFailed={
|
||||||
</div>
|
artifactsError instanceof WorkbenchApiError
|
||||||
|
? artifactsError.message
|
||||||
|
: "Failed to load artifacts."
|
||||||
|
}
|
||||||
|
mutationStatus="No corpus mutation occurred."
|
||||||
|
reproducer="curl -X GET /artifacts"
|
||||||
|
retrySafety="Retry: safe"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ArtifactList
|
<ArtifactList
|
||||||
artifacts={artifactsQuery.data || []}
|
artifacts={artifactsQuery.data || []}
|
||||||
|
|
@ -127,7 +150,12 @@ export function ReplayRoute() {
|
||||||
: ReplayStatus.DIVERGED
|
: ReplayStatus.DIVERGED
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
statement="Selected artifact not found."
|
||||||
|
nextAction="Select a valid artifact from the list."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -209,11 +209,13 @@ describe("W-031 Replay Theater Tests", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ReplayComparisonPanel
|
<MemoryRouter>
|
||||||
artifact={mockArtifactDetail}
|
<ReplayComparisonPanel
|
||||||
comparison={eqComparison}
|
artifact={mockArtifactDetail}
|
||||||
status={ReplayStatus.EQUIVALENT}
|
comparison={eqComparison}
|
||||||
/>
|
status={ReplayStatus.EQUIVALENT}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for calm empty state text
|
// Check for calm empty state text
|
||||||
|
|
@ -262,6 +264,14 @@ describe("W-031 Replay Theater Tests", () => {
|
||||||
expect(badges[2].textContent).toBe("info");
|
expect(badges[2].textContent).toBe("info");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders severity label text ('breaking', 'material', 'low') next to badges", () => {
|
||||||
|
render(<ReplayDiffViewer divergences={mockReplayComparison.divergences} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("severity-label-failure").textContent).toBe("breaking");
|
||||||
|
expect(screen.getByTestId("severity-label-warning").textContent).toBe("material");
|
||||||
|
expect(screen.getByTestId("severity-label-info").textContent).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders nothing (null) with 0 divergences", () => {
|
it("renders nothing (null) with 0 divergences", () => {
|
||||||
const { container } = render(<ReplayDiffViewer divergences={[]} />);
|
const { container } = render(<ReplayDiffViewer divergences={[]} />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
|
|
@ -298,6 +308,36 @@ describe("W-031 Replay Theater Tests", () => {
|
||||||
expect(pathEl.closest("a")).toBeNull();
|
expect(pathEl.closest("a")).toBeNull();
|
||||||
expect(pathEl.textContent).toBe(mockArtifactDetail.path);
|
expect(pathEl.textContent).toBe(mockArtifactDetail.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders timestamp, digest, run id, lane, and explicit severity labels", () => {
|
||||||
|
const detailWithMeta: ArtifactDetail = {
|
||||||
|
...mockArtifactDetail,
|
||||||
|
created_at: "2026-05-26T12:00:00Z",
|
||||||
|
content: {
|
||||||
|
run_id: "run-999",
|
||||||
|
lane: "contemplation_quality",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReplayMetadataTable
|
||||||
|
artifact={detailWithMeta}
|
||||||
|
comparison={mockReplayComparison}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify timestamp
|
||||||
|
expect(screen.getByTestId("artifact-created-at")).toHaveTextContent("2026");
|
||||||
|
|
||||||
|
// Verify run id & lane
|
||||||
|
expect(screen.getByTestId("artifact-run-id")).toHaveTextContent("run-999");
|
||||||
|
expect(screen.getByTestId("artifact-lane")).toHaveTextContent("contemplation_quality");
|
||||||
|
|
||||||
|
// Verify divergence counts with textual labels
|
||||||
|
expect(screen.getByText("Failure (breaking): 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Warning (material): 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Info (low): 1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ReplayRoute", () => {
|
describe("ReplayRoute", () => {
|
||||||
|
|
@ -393,6 +433,82 @@ describe("W-031 Replay Theater Tests", () => {
|
||||||
expect(await screen.findByText("What failed")).toBeInTheDocument();
|
expect(await screen.findByText("What failed")).toBeInTheDocument();
|
||||||
expect(screen.getByText("disk read error")).toBeInTheDocument();
|
expect(screen.getByText("disk read error")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders Selected artifact not found when selected ID returns null detail data", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/artifacts")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/artifacts/missing-id")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
json: () => Promise.resolve({ ok: true, generated_at: "now", data: null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith("/replay/missing-id")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockReplayComparison }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("Unknown route"));
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderWithProviders(<ReplayRoute />, ["/replay?artifactId=missing-id"]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Selected artifact not found.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ErrorState in left pane when artifacts loading fails", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/artifacts")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
generated_at: "now",
|
||||||
|
error: { code: "read_error", message: "Failed to read artifacts index" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("Unknown route"));
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderWithProviders(<ReplayRoute />, ["/replay"]);
|
||||||
|
|
||||||
|
const errorElements = await screen.findAllByText("Failed to read artifacts index");
|
||||||
|
expect(errorElements.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText("No corpus mutation occurred.").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Back to proposal link only when fromProposal query parameter is present", () => {
|
||||||
|
const { unmount } = renderWithProviders(
|
||||||
|
<ReplayComparisonPanel
|
||||||
|
artifact={mockArtifactDetail}
|
||||||
|
comparison={mockReplayComparison}
|
||||||
|
status={ReplayStatus.DIVERGED}
|
||||||
|
/>,
|
||||||
|
["/replay?artifactId=art-trace-1"]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("back-to-proposal")).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ReplayComparisonPanel
|
||||||
|
artifact={mockArtifactDetail}
|
||||||
|
comparison={mockReplayComparison}
|
||||||
|
status={ReplayStatus.DIVERGED}
|
||||||
|
/>,
|
||||||
|
["/replay?artifactId=art-trace-1&fromProposal=proposal-777"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByTestId("back-to-proposal");
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveTextContent("Back to proposal #proposal-777");
|
||||||
|
expect(link.getAttribute("href")).toBe("/proposals?proposal_id=proposal-777");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Anti-motion & Animation Constraints", () => {
|
describe("Anti-motion & Animation Constraints", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue