← Files NGS Analysis WorkbenchARCHIVED FILE
src/App.stories.tsx
54.8 KB · Sep 30, 2026 · 23:20 UTC
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
expect,
fireEvent,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { useState } from "react";
import {
NgsWorkbenchSurface,
type NgsWorkbenchViewModel,
type WorkbenchPage,
} from "./App";
import type {
ComputeTarget,
Pipeline,
RunDetail,
RunObservation,
RunSummary,
} from "./model";
import type { AnalysisReportData } from "./report";
import { nextflowTraceObservation } from "./fixtures/executionObservation";
const meta = {
title: "NGS/App Surfaces",
parameters: { layout: "fullscreen", storyWidth: 1080 },
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
const pipelines: Pipeline[] = [
{
id: "fastq_qc",
title: "FASTQ QC",
workflow: "nf-core/demo",
description: "Basic FASTQ quality checks with FastQC and MultiQC.",
engine: "nextflow",
collection: "nf-core",
catalog: "bundled",
},
{
id: "rnaseq",
title: "Bulk RNA-seq",
workflow: "nf-core/rnaseq",
description: "Bulk RNA-seq alignment, quantification, and MultiQC.",
engine: "nextflow",
collection: "nf-core",
catalog: "bundled",
},
{
id: "rnaseq",
title: "Bulk RNA-seq",
workflow: "/workflows/rnaseq/Snakefile",
description: "Alignment-free quantification with Salmon and MultiQC.",
engine: "snakemake",
catalog: "bundled",
sourceKind: "local",
},
{
id: "sarek",
title: "DNA variant analysis",
workflow: "nf-core/sarek",
description: "Germline or somatic variant analysis using nf-core/sarek.",
engine: "nextflow",
collection: "nf-core",
catalog: "bundled",
},
{
id: "atacseq",
title: "ATAC-seq",
workflow: "nf-core/atacseq",
description: "Alignment, QC, peak calling, and signal outputs.",
engine: "nextflow",
collection: "nf-core",
catalog: "bundled",
},
{
id: "lab_rnaseq",
title: "Lab RNA quantification",
workflow: "/workflows/lab_rnaseq",
description: "Reusable laboratory workflow for expression quantification.",
engine: "snakemake",
catalog: "saved",
sourceKind: "local",
entrypoint: "workflow/Snakefile",
},
];
const selectedPipeline = pipelines[1];
const computeTargets: ComputeTarget[] = [
{
target_id: "local",
title: "This computer",
controllerTransport: "local_process",
executor: "local_process",
workspaceAccess: "local_filesystem",
description: "Run the workflow controller as a process on this computer.",
executorConfiguration: {},
},
{
target_id: "research-slurm",
title: "Research Slurm",
controllerTransport: "ssh",
executor: "slurm",
workspaceAccess: "remote_filesystem",
description: "Run a workflow controller through the configured SSH target.",
workspaceRoot: "/shared/rosalind",
executorConfiguration: { partition: "genomics" },
},
];
const runningRun: RunDetail = {
binding: "nextflow",
pipeline: selectedPipeline,
workflow: selectedPipeline.workflow,
target: { target_id: "local", provider: "ngs-analysis-workbench" },
run_id: "nfcore-rnaseq-20260806-071700-a1b2c3d4",
first_run_id: "nfcore-rnaseq-20260806-071700-a1b2c3d4",
attempt_number: 1,
registry_run_id: "registry-run-id",
revision: 2,
status: "running",
display_name: "Lung cohort RNA-seq",
analysis_summary:
"Lung inflammation profiling is partially executed: FastQC completed, while Salmon quantification remains pending. No differential-expression or biological conclusions can be made yet.",
pid: 48172,
run_dir:
"/workspaces/lung-rnaseq/ngs_runs/nextflow/nfcore-rnaseq-20260806-071700-a1b2c3d4",
command_argv: [
"nextflow",
"run",
"nf-core/rnaseq",
"-r",
"3.18.0",
"-profile",
"docker",
"--input",
"/workspaces/lung-rnaseq/samplesheet.csv",
],
plan_checksum: `sha256:${"a".repeat(64)}`,
warnings: [],
returncode: null,
};
const runningObservation: RunObservation = {
registry_run_id: runningRun.registry_run_id,
revision: runningRun.revision,
status: runningRun.status,
pid: runningRun.pid,
returncode: runningRun.returncode,
failure_summary: runningRun.failure_summary,
warnings: [],
log_tail: [
"executor > local (2)",
"[52/9c2c36] process > NFCORE_RNASEQ:RNASEQ:ALIGN_STAR:STAR_ALIGN (S10) [100%] 1 of 1 ✔",
"[a8/41faae] process > NFCORE_RNASEQ:RNASEQ:QUANTIFY_STAR_SALMON:SALMON_QUANT (S10) [50%] 1 of 2",
].join("\n"),
execution: nextflowTraceObservation,
};
const completedAnalysis: AnalysisReportData = {
title: "Bulk RNA-seq counts and quality control",
description:
"Alignment-free quantification and quality review for the GSE110004 lung RNA-seq cohort.",
summary:
"All six samples completed Salmon quantification and produced gene-level count and TPM matrices. Mapping rates were consistent across the cohort. One sample has elevated duplication and should be reviewed before differential expression, but the analysis remains usable.",
completedAt: "August 7, 2026 at 09:42 UTC",
duration: "14 min 32 sec",
runDirectory:
"/workspaces/lung-rnaseq/ngs_runs/nextflow/nfcore-rnaseq-20260805-142300-9f13a8cd",
artifactCount: 24,
metrics: [
{ label: "Samples", value: "6 / 6", detail: "Quantified", tone: "success" },
{
label: "Median mapping",
value: "82.4%",
detail: "Salmon mapping rate",
tone: "success",
},
{ label: "Genes detected", value: "18,642", detail: "Across all samples" },
{
label: "QC verdict",
value: "Review",
detail: "1 sample warning",
tone: "warning",
},
],
warnings: [
"GSM2956575 has elevated sequence duplication relative to the rest of the cohort.",
],
entries: [
{
id: "fastqc_multiqc_helper",
title: "FastQC MultiQC report",
path: "fastqc/multiqc/multiqc_browser_helper.html",
openUrl:
"http://127.0.0.1:8765/fastqc/multiqc/multiqc_browser_helper.html",
kind: "html_report",
status: "created",
description:
"Read quality, duplication, adapter content, and per-sequence quality across all samples.",
},
{
id: "counts_qc_review_notebook",
title: "Counts and QC review notebook",
path: "notebooks/bulk_rnaseq_counts_qc_review.marimo.py",
kind: "notebook",
status: "created",
description:
"Interactive review of matrices, QC verdicts, and generated reports.",
},
{
id: "gene_num_reads_matrix",
title: "Gene count matrix",
path: "rnaseq_salmon/matrices/gene_num_reads.tsv",
kind: "table",
status: "created",
description:
"Gene-by-sample expected counts aggregated from Salmon transcripts.",
size: "1.8 MB",
},
{
id: "gene_tpm_matrix",
title: "Gene TPM matrix",
path: "rnaseq_salmon/matrices/gene_tpm.tsv",
kind: "table",
status: "created",
description: "Gene-by-sample TPM values for downstream exploration.",
size: "2.4 MB",
},
{
id: "qc_verdict",
title: "QC verdict",
path: "qc/qc_verdict.json",
kind: "json",
status: "created",
description:
"Machine-readable pass, warning, and failure decisions with supporting metrics.",
size: "4 KB",
},
{
id: "normalized_samplesheet",
title: "Normalized sample sheet",
path: "validation/samplesheet.normalized.csv",
kind: "table",
status: "created",
description:
"Resolved input files, sample groups, layout, and row provenance.",
size: "3 KB",
},
],
notes: [
"Raw FASTQs and reference files were treated as read-only inputs.",
"Use the QC verdict together with MultiQC before starting differential expression.",
],
provenance: [
{ label: "Workflow", value: "local_light_snakemake_salmon" },
{ label: "Quantifier", value: "Salmon 1.10.3" },
{ label: "Reference", value: "GENCODE human release 48" },
{ label: "Manifest", value: "run_manifest.json · schema 0.4.0" },
{
label: "Parameter SHA256",
value: "8bfd55084a46726a2005c8f38fd13f27b9f5ba7aab00c1b2bc8682276f831e36",
},
],
sources: [
{ label: "Scientific analysis", path: "analysis_summary.md" },
{ label: "Summary", path: "summary.md" },
{ label: "Run manifest", path: "run_manifest.json" },
{
label: "Review manifest",
path: "visualizations/visualization_manifest.json",
},
{ label: "Artifact index", path: "artifact_index.json" },
],
};
const completedRun: RunDetail = {
...runningRun,
registry_run_id: "registry-run-completed",
run_id: "nfcore-rnaseq-20260805-142300-9f13a8cd",
status: "completed",
display_name: "Baseline lung RNA-seq",
pid: 39215,
run_dir: completedAnalysis.runDirectory,
returncode: 0,
};
const failedRun: RunDetail = {
...runningRun,
registry_run_id: "registry-run-failed",
run_id: "nfcore-sarek-20260804-095500-9ba87d10",
status: "failed",
display_name: "Tumor-normal pilot",
analysis_summary:
"The tumor-normal pilot failed after initial read QC because the reference index was unavailable. Only partial FastQC artifacts are verified; variant calls and biological conclusions are unsupported. Next step: inspect the existing reference configuration and failure log.",
failure_summary: "Reference index was unavailable.",
returncode: 1,
};
const recoveryRootRunId = "nfcore-rnaseq-20260805-134000-0a1b2c3d";
const recoveryAttempts: RunSummary[] = [
[1, "Native bcl2fastq failed under Apple Silicon emulation."],
[2, "BCL2FASTQ requested 72 GB, exceeding the target's 62.8 GB."],
[3, "CheckQC setup exceeded the target's available memory."],
[4, "Output publication failed after workflow tasks completed."],
].map(([attempt, summary]) => {
const attemptNumber = Number(attempt);
const runId =
attemptNumber === 1
? recoveryRootRunId
: `nfcore-rnaseq-20260805-13${
40 + attemptNumber
}00-${attemptNumber}b2c3d4`;
const timestamp = Date.UTC(2026, 7, 5, 20, 30 + attemptNumber * 10);
return {
registry_run_id: `registry-run-recovery-${attemptNumber}`,
run_id: runId,
first_run_id: recoveryRootRunId,
attempt_number: attemptNumber,
binding: "nextflow",
pipeline: "rnaseq",
workflow: "nf-core/rnaseq",
status: "failed",
display_name: "Baseline lung RNA-seq",
analysis_summary: String(summary),
failure_summary: String(summary),
revision: attemptNumber,
workflow_revision: "3.18.0",
profile: "docker",
target: { target_id: "devbox2", provider: "ngs-analysis-workbench" },
run_dir: `/workspaces/lung-rnaseq/ngs_runs/nextflow/rnaseq/${runId}`,
pid: 39000 + attemptNumber,
started_at_ms: timestamp,
completed_at_ms: timestamp + 5 * 60_000,
returncode: 1,
created_at_ms: timestamp - 60_000,
updated_at_ms: timestamp + 5 * 60_000,
} satisfies RunSummary;
});
const runHistory: RunSummary[] = [
{
registry_run_id: runningRun.registry_run_id,
run_id: runningRun.run_id,
first_run_id: runningRun.run_id,
attempt_number: 1,
binding: "nextflow",
pipeline: "rnaseq",
workflow: "nf-core/rnaseq",
status: "running",
display_name: "Lung cohort RNA-seq",
analysis_summary:
"Lung inflammation profiling is partially executed; FastQC completed, while quantification and biological conclusions remain pending.",
revision: 2,
run_dir: runningRun.run_dir,
pid: runningRun.pid ?? null,
started_at_ms: Date.UTC(2026, 7, 6, 18, 8),
completed_at_ms: null,
returncode: null,
created_at_ms: Date.UTC(2026, 7, 6, 18, 7),
updated_at_ms: Date.UTC(2026, 7, 6, 18, 8),
},
...recoveryAttempts,
{
registry_run_id: "registry-run-completed",
run_id: "nfcore-rnaseq-20260805-142300-9f13a8cd",
first_run_id: recoveryRootRunId,
attempt_number: 5,
binding: "nextflow",
pipeline: "rnaseq",
workflow: "nf-core/rnaseq",
status: "completed",
display_name: "Baseline lung RNA-seq",
analysis_summary:
"Six lung-cohort samples support downstream expression analysis; review one high-duplication sample first.",
revision: 3,
run_dir:
"/workspaces/lung-rnaseq/ngs_runs/nextflow/nfcore-rnaseq-20260805-142300-9f13a8cd",
pid: 39215,
started_at_ms: Date.UTC(2026, 7, 5, 21, 23),
completed_at_ms: Date.UTC(2026, 7, 5, 22, 41),
returncode: 0,
created_at_ms: Date.UTC(2026, 7, 5, 21, 22),
updated_at_ms: Date.UTC(2026, 7, 5, 22, 41),
},
{
registry_run_id: "registry-run-failed",
run_id: "nfcore-sarek-20260804-095500-9ba87d10",
first_run_id: "nfcore-sarek-20260804-095500-9ba87d10",
attempt_number: 1,
binding: "nextflow",
pipeline: "sarek",
workflow: "nf-core/sarek",
status: "failed",
display_name: "Tumor-normal pilot",
analysis_summary:
"The pilot failed after initial read QC because the reference index was unavailable; no variant calls are supported.",
failure_summary: "Reference index was unavailable.",
revision: 3,
run_dir:
"/workspaces/tumor-normal-sarek/ngs_runs/nextflow/nfcore-sarek-20260804-095500-9ba87d10",
pid: 31104,
started_at_ms: Date.UTC(2026, 7, 4, 16, 55),
completed_at_ms: Date.UTC(2026, 7, 4, 17, 12),
returncode: 1,
created_at_ms: Date.UTC(2026, 7, 4, 16, 54),
updated_at_ms: Date.UTC(2026, 7, 4, 17, 12),
},
{
registry_run_id: "registry-run-snakemake",
run_id: "snakemake-fastq-qc-20260803-081900-61af390e",
first_run_id: "snakemake-fastq-qc-20260803-081900-61af390e",
attempt_number: 1,
binding: "snakemake",
pipeline: "fastq_qc",
workflow: "workflows/fastq_qc.smk",
status: "completed",
analysis_summary:
"Intake reads passed adapter screening; base-composition bias should be reviewed against the RNA-seq library design.",
revision: 3,
run_dir:
"/workspaces/fastq-intake/ngs_runs/snakemake/snakemake-fastq-qc-20260803-081900-61af390e",
pid: 28411,
started_at_ms: Date.UTC(2026, 7, 3, 15, 19),
completed_at_ms: Date.UTC(2026, 7, 3, 15, 31),
returncode: 0,
created_at_ms: Date.UTC(2026, 7, 3, 15, 18),
updated_at_ms: Date.UTC(2026, 7, 3, 15, 31),
},
];
function workbench(
overrides: Partial<NgsWorkbenchViewModel> = {},
): NgsWorkbenchViewModel {
const selectedRun = overrides.analysisRun;
return {
analysisObservation: selectedRun
? observationForRun(selectedRun)
: undefined,
analysisReport:
selectedRun?.status === "completed"
? {
registry_run_id: selectedRun.registry_run_id,
revision: selectedRun.revision,
availability: "available",
report: completedAnalysis,
}
: undefined,
analysisRun: undefined,
catalogLoading: false,
computeTargets: [],
connected: true,
detailLoading: false,
dismissError: () => undefined,
displayMode: "inline",
error: undefined,
historyLoading: false,
loadCatalog: async () => undefined,
loadComputeTargets: async () => undefined,
loadRunLineage: async (run) =>
runHistory.filter(
(candidate) => candidate.first_run_id === run.first_run_id,
),
loadRunDetail: async (run) => detailForRun(run),
loadRunObservation: async (run) => observationForRun(detailForRun(run)),
observationError: undefined,
observationLoading: false,
operation: undefined,
openArtifact: async () => undefined,
openAnalysis: async () => undefined,
pipelines,
refreshStatus: async () => undefined,
refreshRunHistory: async () => undefined,
reportError: undefined,
reportLoading: false,
runHistory,
targetsError: undefined,
targetsLoading: false,
targetsRequested: false,
...overrides,
};
}
function renderSurface(
viewModel: NgsWorkbenchViewModel,
initialPage?: WorkbenchPage,
) {
return (
<StorySurface initialPage={initialPage} initialViewModel={viewModel} />
);
}
function StorySurface({
initialPage,
initialViewModel,
}: {
initialPage?: WorkbenchPage;
initialViewModel: NgsWorkbenchViewModel;
}) {
const [analysisRun, setAnalysisRun] = useState(initialViewModel.analysisRun);
const [historyLoading, setHistoryLoading] = useState(
initialViewModel.historyLoading,
);
const viewModel: NgsWorkbenchViewModel = {
...initialViewModel,
analysisRun,
...(analysisRun !== initialViewModel.analysisRun
? {
analysisObservation: analysisRun
? observationForRun(analysisRun)
: undefined,
analysisReport:
analysisRun?.registry_run_id === completedRun.registry_run_id
? {
registry_run_id: analysisRun.registry_run_id,
revision: analysisRun.revision,
availability: "available" as const,
report: completedAnalysis,
}
: undefined,
}
: {}),
historyLoading,
openAnalysis: async (run) => {
setHistoryLoading(true);
await Promise.resolve();
setAnalysisRun(await initialViewModel.loadRunDetail(run));
setHistoryLoading(false);
},
};
return (
<NgsWorkbenchSurface initialPage={initialPage} workbench={viewModel} />
);
}
function detailForRun(run: RunSummary): RunDetail {
if (run.registry_run_id === runningRun.registry_run_id) return runningRun;
if (run.registry_run_id === completedRun.registry_run_id) return completedRun;
if (run.registry_run_id === failedRun.registry_run_id) return failedRun;
const pipeline = pipelines.find(
(candidate) =>
candidate.id === run.pipeline && candidate.engine === run.binding,
) ?? {
id: run.pipeline,
title: run.display_name ?? run.workflow,
workflow: run.workflow,
description: "Registered NGS analysis run.",
};
return {
binding: run.binding === "snakemake" ? "snakemake" : "nextflow",
pipeline,
workflow: run.workflow,
target: run.target ?? undefined,
run_id: run.run_id,
first_run_id: run.first_run_id,
attempt_number: run.attempt_number,
registry_run_id: run.registry_run_id,
revision: run.revision,
status: run.status,
display_name: run.display_name,
analysis_summary: run.analysis_summary,
failure_summary: run.failure_summary,
pid: run.pid ?? undefined,
run_dir: run.run_dir,
command_argv: [
run.binding === "snakemake" ? "snakemake" : "nextflow",
"run",
run.workflow,
],
plan_checksum: `sha256:${"b".repeat(64)}`,
warnings:
run.status === "failed"
? ["The workflow exited before all tasks completed."]
: [],
returncode: run.returncode,
};
}
function observationForRun(run: RunDetail): RunObservation {
return {
registry_run_id: run.registry_run_id,
revision: run.revision,
status: run.status,
pid: run.pid,
returncode: run.returncode,
failure_summary: run.failure_summary,
warnings: [],
log_tail:
run.status === "failed"
? "ERROR: A workflow process exited with a non-zero status."
: run.status === "running"
? runningObservation.log_tail
: "Workflow completed successfully.",
execution: nextflowTraceObservation,
};
}
export const OverviewNoActiveRun: Story = {
render: () => renderSurface(workbench()),
};
export const RunHistoryOverview: Story = {
render: () => renderSurface(workbench()),
};
export const RunHistoryNavigation: Story = {
render: () => renderSurface(workbench()),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const historyFailure = canvas.getByText("Failed", {
selector: "[data-ngs-chip]",
});
const failureStyle = getComputedStyle(historyFailure);
const failureAppearance = [
failureStyle.color,
failureStyle.backgroundColor,
];
const historyEngine = canvas.getAllByText("Nextflow", {
selector: "[data-ngs-chip]",
})[0];
const chipGeometry = (element: HTMLElement) => {
const style = getComputedStyle(element);
return [
style.height,
style.borderRadius,
style.fontSize,
style.fontWeight,
style.lineHeight,
style.padding,
];
};
const historyGeometry = chipGeometry(historyFailure);
await expect(chipGeometry(historyEngine)).toEqual(historyGeometry);
await expect(historyFailure.querySelector(".oai-icon")).not.toBeNull();
await expect(historyEngine.querySelector(".oai-icon")).toBeNull();
await userEvent.click(
canvas.getByRole("button", { name: /Completed Baseline lung RNA-seq/ }),
);
await expect(
canvas.getByRole("complementary", { name: "Run history" }),
).toBeInTheDocument();
await expect(
canvas.getAllByRole("tab", { name: /Attempt \d/ }),
).toHaveLength(5);
const analysisHeading = canvas.getByRole("heading", {
name: "Bulk RNA-seq counts and quality control",
});
await userEvent.click(canvas.getByRole("tab", { name: /Attempt 2/ }));
await expect(
canvas.getByText(/BCL2FASTQ requested 72 GB/),
).toBeInTheDocument();
const attemptFailure = canvas.getByText("Failed", {
selector: "[data-ngs-chip]",
});
const failedAttempt = canvas.getByRole("tabpanel", {
name: "Attempt 2: Failed",
});
await expect(
within(failedAttempt).queryByRole("region", {
name: "Execution details",
}),
).toBeNull();
await expect(
within(failedAttempt).getByRole("heading", { name: "Recorded failure" }),
).toBeVisible();
const attemptStyle = getComputedStyle(attemptFailure);
await expect(chipGeometry(attemptFailure)).toEqual(historyGeometry);
await expect([attemptStyle.color, attemptStyle.backgroundColor]).toEqual(
failureAppearance,
);
await expect(attemptFailure.querySelector(".oai-icon")).not.toBeNull();
const failedTabIcon = canvas
.getByRole("tab", { name: "Attempt 2: Failed" })
.querySelector<HTMLElement>(".oai-icon")!;
await expect(
failedTabIcon.style.getPropertyValue("--oai-icon-source"),
).toBe(
attemptFailure
.querySelector<HTMLElement>(".oai-icon")!
.style.getPropertyValue("--oai-icon-source"),
);
await expect(
canvas.getAllByRole("tab", { name: /Attempt \d/ }),
).toHaveLength(5);
await expect(analysisHeading).toBeInTheDocument();
const selectedTab = canvas.getByRole("tab", {
name: "Attempt 2: Failed",
});
await userEvent.keyboard("{ArrowRight}");
const nextTab = canvas.getByRole("tab", { name: /Attempt 3:/ });
await expect(nextTab).toHaveFocus();
await expect(nextTab).toHaveAttribute("aria-selected", "false");
await expect(selectedTab).toHaveAttribute("aria-selected", "true");
await userEvent.keyboard("{Enter}");
await waitFor(() =>
expect(nextTab).toHaveAttribute("aria-selected", "true"),
);
await expect(analysisHeading).toBeInTheDocument();
await userEvent.keyboard("{Home}");
await expect(canvas.getByRole("tab", { name: /Attempt 1:/ })).toHaveFocus();
await userEvent.keyboard("{End}");
await expect(canvas.getByRole("tab", { name: /Attempt 5:/ })).toHaveFocus();
await userEvent.click(selectedTab);
const inspector = canvas.getByRole("complementary", {
name: "Run history",
});
const contextBar = canvas.getByRole("navigation", {
name: "Workbench pages",
}).parentElement!;
await expect(inspector.getBoundingClientRect().top).toBe(
contextBar.getBoundingClientRect().top,
);
await expect(
within(inspector).getAllByRole("button", { name: "Close run history" }),
).toHaveLength(1);
await userEvent.click(
canvas.getByRole("button", { name: "Close run history" }),
);
const openToggle = canvas.getByRole("button", { name: "Open run history" });
await expect(openToggle).toHaveAttribute("aria-expanded", "false");
await waitFor(() => expect(openToggle).toHaveFocus());
await expect(
canvas.queryByRole("complementary", { name: "Run history" }),
).not.toBeInTheDocument();
await expect(analysisHeading).toBeInTheDocument();
await userEvent.keyboard("{Enter}");
const closeToggle = canvas.getByRole("button", {
name: "Close run history",
});
await expect(closeToggle).toHaveAttribute("aria-expanded", "true");
await waitFor(() => expect(closeToggle).toHaveFocus());
await expect(
canvas.getByRole("tab", { name: "Attempt 2: Failed" }),
).toHaveAttribute("aria-selected", "true");
},
};
export const RunHistoryClosed: Story = {
render: () => renderSurface(workbench({ analysisRun: completedRun }), "run"),
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Close run history" }),
);
},
};
export const OverviewCompletedRunInHistory: Story = {
render: () => renderSurface(workbench({ analysisRun: completedRun })),
};
export const OverviewScientificSummaryReady: Story = {
render: () =>
renderSurface(
workbench({
runHistory: [
{
...runHistory[3],
display_name:
"Azure-backed Brix Snakemake FastQC: paired SRR6357072",
analysis_summary:
"FastQC of paired SRR6357072: 50,000 high-quality reads per mate; " +
"review duplication and sequence-content warnings.",
},
...runHistory.slice(0, 3),
],
}),
),
};
export const OverviewRunning: Story = {
render: () => renderSurface(workbench({ analysisRun: runningRun })),
};
export const OverviewEmptyHistory: Story = {
render: () => renderSurface(workbench({ runHistory: [] })),
};
export const Workflows: Story = {
render: () => renderSurface(workbench(), "workflows"),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const workflowCard = canvas.getByRole("button", {
name: /DNA variant analysis/i,
});
await userEvent.click(workflowCard);
const dialog = await canvas.findByRole("dialog", {
name: "DNA variant analysis",
});
await waitFor(() =>
expect(
within(dialog).getByText(
"Germline or somatic variant analysis using nf-core/sarek.",
),
).toBeVisible(),
);
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(dialog).not.toBeInTheDocument());
await expect(workflowCard).toHaveFocus();
await userEvent.click(workflowCard);
const reopenedDialog = await canvas.findByRole("dialog", {
name: "DNA variant analysis",
});
await userEvent.click(
within(reopenedDialog).getByRole("button", {
name: "Close pipeline details",
}),
);
await waitFor(() => expect(reopenedDialog).not.toBeInTheDocument());
await expect(workflowCard).toHaveFocus();
await userEvent.click(workflowCard);
const backdropDialog = await canvas.findByRole("dialog", {
name: "DNA variant analysis",
});
fireEvent.click(backdropDialog);
await waitFor(() => expect(backdropDialog).not.toBeInTheDocument());
await expect(workflowCard).toHaveFocus();
},
};
export const Compute: Story = {
render: () => renderSurface(workbench({ computeTargets }), "compute"),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const targetRow = canvas.getByRole("button", { name: /Research Slurm/i });
await userEvent.click(targetRow);
const dialog = await canvas.findByRole("dialog", {
name: "Research Slurm",
});
await waitFor(() =>
expect(within(dialog).getByText("Remote filesystem")).toBeVisible(),
);
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(),
);
await expect(targetRow).toHaveFocus();
await userEvent.click(targetRow);
await userEvent.click(await canvas.findByRole("button", { name: "Close" }));
await waitFor(() =>
expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(),
);
await expect(targetRow).toHaveFocus();
},
};
export const ComputeNarrow: Story = {
parameters: { storyWidth: 480 },
render: () => renderSurface(workbench({ computeTargets }), "compute"),
};
export const WorkflowsEmpty: Story = {
render: () => renderSurface(workbench({ pipelines: [] }), "workflows"),
};
export const WorkflowsLoading: Story = {
render: () =>
renderSurface(
workbench({ catalogLoading: true, pipelines: [] }),
"workflows",
),
};
export const FullscreenOverview: Story = {
parameters: { hostDisplayMode: "fullscreen", storyWidth: "none" },
render: () => renderSurface(workbench({ displayMode: "fullscreen" })),
};
export const FullscreenWorkflows: Story = {
parameters: { hostDisplayMode: "fullscreen", storyWidth: "none" },
render: () =>
renderSurface(workbench({ displayMode: "fullscreen" }), "workflows"),
};
export const FullscreenRunning: Story = {
parameters: { hostDisplayMode: "fullscreen", storyWidth: "none" },
render: () =>
renderSurface(
workbench({
analysisRun: runningRun,
displayMode: "fullscreen",
}),
"run",
),
};
export const FullscreenResults: Story = {
parameters: { hostDisplayMode: "fullscreen", storyWidth: "none" },
render: () =>
renderSurface(
workbench({
analysisRun: completedRun,
displayMode: "fullscreen",
}),
"run",
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const count = canvas.getByText("4", { selector: "[data-ngs-chip]" });
const taskGroup = canvas.getByRole("group", { name: "Task attempts" });
const tasks = taskGroup.querySelector("summary")!;
const memoryLabels = canvas.getAllByText("Peak memory", { selector: "dt" });
await expect(memoryLabels[0]).not.toBeVisible();
await userEvent.click(tasks);
await waitFor(() => expect(memoryLabels[0]).toBeVisible());
for (const label of memoryLabels) {
await expect(label).toBeVisible();
await expect(label.getBoundingClientRect().height).toBeLessThanOrEqual(
parseFloat(getComputedStyle(label).lineHeight) + 1,
);
}
const completed = canvas.getAllByText("Completed", {
selector: "[data-ngs-chip]",
});
const analysisCompleted = canvas.getByText("Analysis completed", {
selector: "[data-ngs-chip]",
});
for (const property of [
"height",
"fontSize",
"borderRadius",
"backgroundColor",
] as const) {
await expect(getComputedStyle(analysisCompleted)[property]).toBe(
getComputedStyle(completed[0])[property],
);
}
// Task identity layout must not turn chip contents into a vertical grid.
for (const chip of completed) {
await expect(chip.getBoundingClientRect().height).toBe(
count.getBoundingClientRect().height,
);
await expect(getComputedStyle(chip).display).toMatch(/^(inline-)?flex$/);
}
const analysisPane = canvas.getByRole("region", {
name: "Analysis and execution",
});
const reportTitle = within(analysisPane).getByRole("heading", {
name: completedAnalysis.title,
});
const summaryHeading = within(analysisPane).getByRole("heading", {
name: "Analysis summary",
});
await expect(
parseFloat(getComputedStyle(reportTitle).fontSize),
).toBeGreaterThan(parseFloat(getComputedStyle(summaryHeading).fontSize));
const reportHeader = reportTitle.closest("header")!;
const reportRunId = reportHeader.querySelector("code")!;
const reportMetadata = reportRunId.parentElement!.getBoundingClientRect();
const reportHeaderBounds = reportHeader.getBoundingClientRect();
await expect(reportMetadata.left).toBe(reportHeaderBounds.left);
await expect(reportMetadata.right).toBe(reportHeaderBounds.right);
await expect(reportRunId.scrollWidth).toBeLessThanOrEqual(
reportRunId.clientWidth,
);
const attemptPane = canvas.getByRole("tabpanel", {
name: "Attempt 5: Completed",
});
const summaryRows = ["Status", "Finished", "Progress"].map((label) =>
within(attemptPane)
.getAllByText(label, { selector: "dt" })
.find((element) => !element.closest("details"))!
.parentElement!.getBoundingClientRect(),
);
await expect(
Math.abs(
summaryRows[1].top -
summaryRows[0].top -
(summaryRows[2].top - summaryRows[1].top),
),
).toBeLessThanOrEqual(1);
await expect(within(attemptPane).queryByText("Latest task")).toBeNull();
for (const label of ["Finished", "Progress"]) {
const term = within(attemptPane)
.getAllByText(label, { selector: "dt" })
.find((element) => !element.closest("details"))!;
await expect(getComputedStyle(term).fontSize).toBe(
getComputedStyle(term.nextElementSibling!).fontSize,
);
}
const warning = canvas.getByRole("region", { name: "Review recommended" });
const warningIcon = warning.querySelector<HTMLElement>(".oai-icon")!;
await expect(warningIcon.getBoundingClientRect().width).toBe(20);
await expect(getComputedStyle(warningIcon).backgroundColor).toBe(
getComputedStyle(warningIcon).color,
);
const completedTabIcon = canvas
.getByRole("tab", { name: "Attempt 5: Completed" })
.querySelector<HTMLElement>(".oai-icon")!;
const completedStatusIcon = within(attemptPane)
.getAllByText("Completed", { selector: "[data-ngs-chip]" })[0]
.querySelector<HTMLElement>(".oai-icon")!;
await expect(
completedTabIcon.style.getPropertyValue("--oai-icon-source"),
).toBe(completedStatusIcon.style.getPropertyValue("--oai-icon-source"));
for (const icon of [completedTabIcon, completedStatusIcon]) {
await expect(icon.getBoundingClientRect().width).toBe(16);
await expect(icon.getBoundingClientRect().height).toBe(16);
}
const refresh = within(attemptPane).getByRole("button", {
name: "Refresh status",
});
await expect(refresh.textContent).toBe("");
await expect(refresh.getBoundingClientRect().width).toBe(
refresh.getBoundingClientRect().height,
);
await userEvent.hover(refresh);
const tooltip = await canvas.findByRole("tooltip", {
name: "Refresh status",
});
await expect(tooltip).toBeVisible();
await expect(refresh).toHaveAttribute("aria-describedby", tooltip.id);
await expect(tooltip.closest(".ngs-theme")).toBe(
refresh.closest(".ngs-theme"),
);
await userEvent.unhover(refresh);
await waitFor(() => expect(canvas.queryByRole("tooltip")).toBeNull());
refresh.focus();
await userEvent.tab({ shift: true });
await userEvent.tab();
await expect(refresh).toHaveFocus();
await expect(await canvas.findByRole("tooltip")).toBeVisible();
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(canvas.queryByRole("tooltip")).toBeNull());
const heading = within(attemptPane).getByRole("heading", {
name: "Attempt status",
});
await expect(refresh.getBoundingClientRect().bottom).toBeLessThanOrEqual(
summaryRows[0].top,
);
await expect(
Math.abs(
refresh.getBoundingClientRect().top +
refresh.getBoundingClientRect().height / 2 -
(heading.getBoundingClientRect().top +
heading.getBoundingClientRect().height / 2),
),
).toBeLessThanOrEqual(1);
const contextNav = canvas.getByRole("navigation", {
name: "Workbench pages",
});
const contextTop = contextNav.getBoundingClientRect().top;
const analysisTop = analysisPane.getBoundingClientRect().top;
const appBar = canvas.getByRole("banner").getBoundingClientRect();
const previewFrame = canvas
.getByRole("banner")
.closest(".ngs-storybook-shell")!
.getBoundingClientRect();
await expect(appBar.left).toBe(previewFrame.left);
await expect(appBar.right).toBe(previewFrame.right);
const contextBar = contextNav.parentElement!.getBoundingClientRect();
// The persistent context bar must not apply a second sticky offset inside
// the fullscreen grid and overlap the top of the report.
await expect(contextBar.top).toBe(appBar.bottom);
await expect(contextBar.bottom).toBe(analysisTop);
await expect(
canvas
.getByRole("complementary", {
name: "Run history",
})
.getBoundingClientRect().top,
).toBe(contextBar.top);
await expect(
canvas.getByRole("tab", {
name: "Attempt 5: Completed",
}),
).toHaveTextContent("Attempt 5");
attemptPane.scrollTop = 180;
await expect(attemptPane.scrollTop).toBeGreaterThan(0);
await expect(analysisPane.scrollTop).toBe(0);
const attemptScroll = attemptPane.scrollTop;
analysisPane.scrollTop = 180;
await expect(analysisPane.scrollTop).toBeGreaterThan(0);
await expect(attemptPane.scrollTop).toBe(attemptScroll);
await expect(contextNav.getBoundingClientRect().top).toBe(contextTop);
await expect(analysisPane.getBoundingClientRect().top).toBe(analysisTop);
attemptPane.scrollTop = 0;
analysisPane.scrollTop = 0;
await userEvent.click(tasks);
await expect(memoryLabels[0]).not.toBeVisible();
const disclosureRows = [
"Task attempts",
"Execution evidence",
"Run metadata",
"Recorded command",
"Execution log tail",
].map((name) =>
within(attemptPane)
.getByText(name, {
selector:
name === "Task attempts"
? "summary > span:not(.oai-icon)"
: "summary",
})
.closest("summary")!
.getBoundingClientRect(),
);
for (let index = 1; index < disclosureRows.length; index++) {
await expect(disclosureRows[index].height).toBe(disclosureRows[0].height);
await expect(
Math.abs(disclosureRows[index].top - disclosureRows[index - 1].bottom),
).toBeLessThanOrEqual(1);
}
if (document.activeElement === attemptPane) attemptPane.blur();
},
};
function renderAttemptHistory(attemptCount: number) {
const attempts: RunSummary[] = Array.from(
{ length: attemptCount - 1 },
(_, index) => ({
...recoveryAttempts[index % recoveryAttempts.length],
registry_run_id: `registry-run-long-history-${index + 1}`,
run_id: `workflow-long-history-${index + 1}`,
attempt_number: index + 1,
}),
);
const completedSummary = runHistory.find(
(run) => run.registry_run_id === completedRun.registry_run_id,
)!;
attempts.push({ ...completedSummary, attempt_number: attemptCount });
return renderSurface(
workbench({
analysisRun: { ...completedRun, attempt_number: attemptCount },
displayMode: "fullscreen",
runHistory: attempts,
loadRunLineage: async () => attempts,
}),
"run",
);
}
export const TwoAttemptHistory: Story = {
parameters: { hostDisplayMode: "fullscreen", storyWidth: "none" },
render: () => renderAttemptHistory(2),
play: async ({ canvasElement }) => {
const list = await within(canvasElement).findByRole("tablist", {
name: "Workflow attempts",
});
await waitFor(() => {
expect(within(list).getAllByRole("tab")).toHaveLength(2);
expect(list.scrollWidth).toBeLessThanOrEqual(list.clientWidth + 1);
expect(list).toHaveAttribute("data-overflow-left", "false");
expect(list).toHaveAttribute("data-overflow-right", "false");
});
},
};
export const LongAttemptHistory: Story = {
parameters: { hostDisplayMode: "fullscreen", storyWidth: "none" },
render: () => renderAttemptHistory(20),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const list = await canvas.findByRole("tablist", {
name: "Workflow attempts",
});
await waitFor(() =>
expect(within(list).getAllByRole("tab")).toHaveLength(20),
);
const first = within(list).getByRole("tab", { name: "Attempt 1: Failed" });
const last = within(list).getByRole("tab", {
name: "Attempt 20: Completed",
});
const report = canvas.getByRole("heading", {
name: "Bulk RNA-seq counts and quality control",
});
const analysisPane = canvas.getByRole("region", {
name: "Analysis and execution",
});
const reportTop = report.getBoundingClientRect().top;
const expectSelectedVisible = async (tab: HTMLElement) => {
await waitFor(() => {
const listRect = list.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
expect(tabRect.left).toBeGreaterThanOrEqual(listRect.left - 1);
expect(tabRect.right).toBeLessThanOrEqual(listRect.right + 1);
const indicator = list.querySelector<HTMLElement>(
'[role="presentation"]',
)!;
const indicatorRect = indicator.getBoundingClientRect();
expect(Math.abs(indicatorRect.left - tabRect.left)).toBeLessThanOrEqual(
1,
);
expect(
Math.abs(indicatorRect.width - tabRect.width),
).toBeLessThanOrEqual(1);
expect(indicatorRect.height).toBe(2);
});
};
await expect(list.scrollWidth).toBeGreaterThan(list.clientWidth);
await expectSelectedVisible(last);
await expect(list.scrollLeft).toBeGreaterThan(0);
await waitFor(() => {
expect(list).toHaveAttribute("data-overflow-left", "true");
expect(list).toHaveAttribute("data-overflow-right", "false");
});
await expect(getComputedStyle(list).maskImage).not.toBe("none");
await userEvent.click(last);
list.scrollLeft = (list.scrollWidth - list.clientWidth) / 2;
fireEvent.scroll(list);
await waitFor(() => {
expect(list).toHaveAttribute("data-overflow-left", "true");
expect(list).toHaveAttribute("data-overflow-right", "true");
});
await userEvent.keyboard("{Home}");
await expect(first).toHaveFocus();
await expect(last).toHaveAttribute("aria-selected", "true");
await userEvent.keyboard("{Enter}");
await expectSelectedVisible(first);
await waitFor(() => {
expect(list).toHaveAttribute("data-overflow-left", "false");
expect(list).toHaveAttribute("data-overflow-right", "true");
});
await expect(first).toHaveAttribute("aria-selected", "true");
await waitFor(() =>
expect(canvas.getAllByRole("tabpanel")).toHaveLength(1),
);
await expect(
canvas.getByRole("tabpanel", {
name: "Attempt 1: Failed",
}),
).toHaveAttribute("aria-labelledby", first.id);
await userEvent.keyboard("{End}{Enter}");
await expectSelectedVisible(last);
await expect(last).toHaveAttribute("aria-selected", "true");
await waitFor(() =>
expect(canvas.getAllByRole("tabpanel")).toHaveLength(1),
);
await expect(
canvas.getByRole("tabpanel", {
name: "Attempt 20: Completed",
}),
).toHaveAttribute("id", last.getAttribute("aria-controls"));
await expect(report).toBeInTheDocument();
await expect(report.getBoundingClientRect().top).toBe(reportTop);
await expect(analysisPane.scrollTop).toBe(0);
},
};
export const CompletedWithoutAnalysis: Story = {
render: () =>
renderSurface(
workbench({
analysisRun: completedRun,
analysisReport: {
registry_run_id: completedRun.registry_run_id,
revision: completedRun.revision,
availability: "available",
report: {
...completedAnalysis,
summary: "",
sources: completedAnalysis.sources.filter(
(source) => source.path !== "analysis_summary.md",
),
},
},
}),
"run",
),
};
export const FailedRunDetail: Story = {
render: () => renderSurface(workbench({ analysisRun: failedRun }), "run"),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inspector = canvas.getByRole("complementary", {
name: "Run history",
});
await expect(within(inspector).queryByRole("tablist")).toBeNull();
await expect(within(inspector).queryByRole("tab")).toBeNull();
await expect(within(inspector).queryByRole("tabpanel")).toBeNull();
const attempt = within(inspector).getByRole("region", {
name: "Attempt 1: Failed",
});
await expect(
within(attempt).getByRole("heading", {
name: "Attempt status",
}),
).toBeVisible();
await expect(
within(attempt).getByRole("button", {
name: "Refresh status",
}),
).toBeVisible();
},
};
export const NarrowOverview: Story = {
parameters: { storyWidth: 420 },
render: () => renderSurface(workbench({ analysisRun: runningRun })),
};
export const NarrowWorkflows: Story = {
parameters: { storyWidth: 420 },
render: () => renderSurface(workbench(), "workflows"),
};
export const DarkRunning: Story = {
parameters: { theme: "dark" },
render: () => renderSurface(workbench({ analysisRun: runningRun })),
};
export const DarkResults: Story = {
parameters: {
hostDisplayMode: "fullscreen",
storyWidth: "none",
theme: "dark",
},
render: () =>
renderSurface(
workbench({
analysisRun: completedRun,
displayMode: "fullscreen",
}),
"run",
),
};
// These fixtures exercise the desktop host contract without requiring a live MCP host.
export const CodexLightResults: Story = {
...FullscreenResults,
parameters: { ...FullscreenResults.parameters, hostTheme: "codex-light" },
play: async ({ canvasElement }) => {
const action = within(canvasElement).getAllByRole("button", {
name: "Open FastQC MultiQC report",
})[0];
// The host inverse token is a translucent surface. It must not dim button text.
await expect(getComputedStyle(action).color).not.toMatch(
/\/\s*0\.|rgba\([^)]*,\s*0\./,
);
},
};
export const CodexDarkResults: Story = {
...FullscreenResults,
parameters: { ...FullscreenResults.parameters, hostTheme: "codex-dark" },
};
export const FallbackDarkResults: Story = {
...FullscreenResults,
parameters: { ...FullscreenResults.parameters, hostTheme: "fallback-dark" },
};
export const FallbackLightResults: Story = {
...FullscreenResults,
parameters: { ...FullscreenResults.parameters, hostTheme: "fallback-light" },
};
export const HostTypographyAndFocus: Story = {
...Workflows,
parameters: {
hostTheme: "codex-light",
storyWidth: 420,
hostVariables: {
"--font-sans": "Georgia, serif",
"--font-text-md-size": "16px",
"--border-radius-sm": "8px",
"--color-ring-primary": "rgb(103, 58, 183)",
},
},
play: async (context) => {
const canvas = within(context.canvasElement);
const runs = canvas.getByRole("button", { name: /^Runs$/ });
await expect(getComputedStyle(runs).fontFamily).toContain("Georgia");
await expect(getComputedStyle(runs).fontSize).toBe("16px");
await expect(getComputedStyle(runs).borderRadius).toBe("12px");
await Workflows.play?.(context);
},
};
export const NarrowRunningDetail: Story = {
parameters: { storyWidth: 390, hostTheme: "codex-light" },
render: () => renderSurface(workbench({ analysisRun: runningRun }), "run"),
};
export const NarrowAttemptsDrawer: Story = {
parameters: {
storyWidth: 390,
hostTheme: "codex-light",
hostDisplayMode: "fullscreen",
},
render: () =>
renderSurface(
workbench({ analysisRun: completedRun, displayMode: "fullscreen" }),
"run",
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByRole("button", { name: "Open run history" });
await waitFor(() =>
expect(toggle).toHaveAttribute("aria-haspopup", "dialog"),
);
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
const analysis = canvas.getByRole("heading", {
name: "Bulk RNA-seq counts and quality control",
});
await expect(analysis).toBeVisible();
await userEvent.click(toggle);
const drawer = await canvas.findByRole("dialog", { name: "Run history" });
await expect(drawer.matches(":modal")).toBe(true);
await expect(drawer.contains(document.activeElement)).toBe(true);
await expect(getComputedStyle(drawer).backgroundColor).not.toMatch(
/\/\s*0\.|rgba\([^)]*,\s*0\./,
);
await userEvent.click(
within(drawer).getByRole("tab", { name: "Attempt 2: Failed" }),
);
await waitFor(() =>
expect(
within(drawer).getByText(/BCL2FASTQ requested 72 GB/),
).toBeVisible(),
);
const refresh = within(drawer).getByRole("button", {
name: "Refresh status",
});
await userEvent.hover(refresh);
await expect(
await within(drawer).findByRole("tooltip", { name: "Refresh status" }),
).toBeVisible();
refresh.focus();
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(within(drawer).queryByRole("tooltip")).toBeNull(),
);
await expect(drawer.matches(":modal")).toBe(true);
await userEvent.unhover(refresh);
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(),
);
await expect(toggle).toHaveFocus();
await expect(analysis).toBeVisible();
await userEvent.click(toggle);
await expect(
within(drawer).getByRole("tab", { name: "Attempt 2: Failed" }),
).toHaveAttribute("aria-selected", "true");
fireEvent.click(drawer);
await waitFor(() =>
expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(),
);
await expect(toggle).toHaveFocus();
await userEvent.click(toggle);
await userEvent.click(
within(drawer).getByRole("button", { name: "Close run history" }),
);
await waitFor(() =>
expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(),
);
await expect(toggle).toHaveFocus();
},
};
export const OpeningWorkbench: Story = {
render: () =>
renderSurface(
workbench({ connected: false, catalogLoading: true, runHistory: [] }),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toHaveTextContent(
"Opening workbench",
);
await expect(
canvas.queryByRole("button", { name: "Refresh history" }),
).not.toBeInTheDocument();
},
};
export const HostConnectionFailed: Story = {
render: () =>
renderSurface(
workbench({
connected: false,
catalogLoading: false,
pipelines: [],
error: "Could not open the workbench. The MCP host did not respond.",
}),
"workflows",
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
"The MCP host did not respond",
);
await expect(
canvas.queryByText("Opening workbench…"),
).not.toBeInTheDocument();
},
};
export const ReportDetailsDisclosure: Story = {
parameters: { storyWidth: 1280 },
render: () => renderSurface(workbench({ analysisRun: completedRun }), "run"),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Close run history" }),
);
const sources = canvas.getByText("Data sources", { selector: "summary" });
const methods = canvas.getByText("Methods and provenance", {
selector: "summary",
});
const index = canvas.getByText(/Artifact index \(/, {
selector: "summary",
});
const sourceFile = canvas
.getByText("analysis_summary.md", { selector: "span" })
.closest("dd")!;
await expect(sourceFile).not.toBeVisible();
await userEvent.click(sources);
await expect(sourceFile).toBeVisible();
await waitFor(() =>
expect(sourceFile.getBoundingClientRect().width).toBeGreaterThan(240),
);
const details = canvas.getByRole("complementary", {
name: "Analysis details",
});
const folderHeading = within(details).getByRole("heading", {
name: "Run folder",
});
const folderPath = within(details).getByText(
completedAnalysis.runDirectory,
{
selector: "code",
},
);
const actions = within(details).getByRole("group", {
name: "Run folder actions",
});
const folderButtons = within(actions).getAllByRole("button");
const note = within(details)
.getByRole("heading", { name: "Notes" })
.parentElement!.querySelector("li")!;
const bodyFontSize = getComputedStyle(note).fontSize;
for (const element of [
folderPath,
sourceFile,
sourceFile.previousElementSibling!,
sources,
...folderButtons,
]) {
await expect(getComputedStyle(element).fontSize).toBe(bodyFontSize);
}
const firstAction = folderButtons[0].getBoundingClientRect();
const secondAction = folderButtons[1].getBoundingClientRect();
await expect(firstAction.top).toBe(secondAction.top);
await expect(firstAction.height).toBe(secondAction.height);
await expect(actions.getBoundingClientRect().top).toBeGreaterThanOrEqual(
folderPath.getBoundingClientRect().bottom,
);
const disclosureLabel = document.createRange();
disclosureLabel.selectNodeContents(sources.firstChild!);
await expect(disclosureLabel.getBoundingClientRect().left).toBe(
folderHeading.getBoundingClientRect().left,
);
await expect(firstAction.left).toBe(
folderHeading.getBoundingClientRect().left,
);
const manifestFilename = canvas.getByText("visualization_manifest.json", {
selector: "span",
});
await expect(
manifestFilename.getBoundingClientRect().height,
).toBeLessThanOrEqual(
parseFloat(getComputedStyle(manifestFilename).lineHeight) + 1,
);
sources.focus();
await expect(sources).toHaveFocus();
await userEvent.click(sources);
await expect(sources.closest("details")).not.toHaveAttribute("open");
await waitFor(() =>
expect(
index.getBoundingClientRect().top -
methods.getBoundingClientRect().bottom,
).toBeLessThanOrEqual(1),
);
await userEvent.click(
canvas.getByRole("button", { name: "Browse run files" }),
);
await expect(index.closest("details")).toHaveAttribute("open");
await expect(
canvas.getByRole("region", { name: "Review recommended" }),
).toBeVisible();
const copy = canvas.getByRole("button", {
name: "Copy path for Gene count matrix",
});
const copyIcon = copy.querySelector<HTMLElement>(".oai-icon")!;
const idleIcon = copyIcon.style.getPropertyValue("--oai-icon-source");
const idleIconBounds = copyIcon.getBoundingClientRect();
// Synthetic clicks do not grant native clipboard permission in every host.
const writeText = spyOn(
navigator.clipboard,
"writeText",
).mockResolvedValue();
try {
await userEvent.click(copy);
await waitFor(() =>
expect(copy).toHaveAttribute("data-status", "copied"),
);
await expect(writeText).toHaveBeenCalledWith(
`${completedAnalysis.runDirectory}/rnaseq_salmon/matrices/gene_num_reads.tsv`,
);
await expect(copy).toHaveTextContent("Copied");
await expect(
copyIcon.style.getPropertyValue("--oai-icon-source"),
).not.toBe(idleIcon);
await expect(copyIcon.getBoundingClientRect().width).toBe(16);
await expect(copyIcon.getBoundingClientRect().height).toBe(16);
await expect(copyIcon.getBoundingClientRect().width).toBe(
idleIconBounds.width,
);
} finally {
writeText.mockRestore();
}
},
};
SHA-256: 63fa0d2fd3cb80fda799aa8fb217f5dbcb1e31d537acab336d7b346d7ec61873