Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

242 changes: 121 additions & 121 deletions ui/dist/assets/index-BxuZLhmE.js → ui/dist/assets/index-CMPjj35C.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ui/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-BxuZLhmE.js"></script>
<script type="module" crossorigin src="/assets/index-CMPjj35C.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZskjF0U.css">
</head>
<body>
Expand Down
21 changes: 19 additions & 2 deletions ui/src/components/ExperimentsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { WorkspaceEmptyState } from "./WorkspaceEmptyState";
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { FlaskConical, CircleStop, FolderTree, GitBranch, Terminal } from "lucide-react";
import { useState } from "react";
import { fmtNumber, runDisplayStatus, timeAgo, type Experiment, type Run } from "../api";
import { useEffect, useState } from "react";
import { fmtDuration, fmtNumber, runDisplayStatus, timeAgo, type Experiment, type Run } from "../api";
import { StatusBadge } from "./StatusBadge";
import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
import { Button } from "./ui";
Expand Down Expand Up @@ -32,6 +32,18 @@ export function ExperimentsTable({
}) {
const [pendingCancellation, setPendingCancellation] = useState<ReadonlySet<string>>(new Set());
const [cancelError, setCancelError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const hasLiveRun = runs.some(
(run) => run.status === "running" || run.status === "starting",
);

useEffect(() => {
if (!hasLiveRun) return;
setNow(Date.now());
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [hasLiveRun]);

const runsByExperiment = new Map<string, Run[]>();
for (const run of runs) {
const experimentRuns = runsByExperiment.get(run.experimentId);
Expand Down Expand Up @@ -137,6 +149,11 @@ export function ExperimentsTable({
</div>
<div className="experiment-table-latest flex items-center gap-1.5 min-w-0 text-subtext text-sm font-medium whitespace-nowrap">
<span>{latestRun ? timeAgo(latestRun.createdAt) : m.experiments_not_run_yet()}</span>
{latestRun && (
<span title={m.experiment_overview_duration()}>
{fmtDuration((latestRun.endedAt ?? now) - latestRun.createdAt)}
</span>
)}
</div>
</div>
<div
Expand Down
157 changes: 157 additions & 0 deletions ui/tests/experimentsTable.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import test from "node:test";
import ts from "typescript";

const require = createRequire(import.meta.url);
const jsx = (type, props) => ({ type, props });
let hookState = [];
let hookIndex = 0;
let effects = [];

const react = {
useState(initialValue) {
const index = hookIndex++;
if (index >= hookState.length) {
hookState[index] = typeof initialValue === "function" ? initialValue() : initialValue;
}
return [hookState[index], (value) => {
hookState[index] = typeof value === "function" ? value(hookState[index]) : value;
}];
},
useEffect(effect) {
effects.push(effect);
},
};

const messages = {
m: new Proxy({}, { get: (_, name) => () => String(name) }),
};
const icons = new Proxy({}, { get: () => () => null });
const mocks = {
"./WorkspaceEmptyState": { WorkspaceEmptyState: () => null },
"../paraglide/messages.js": messages,
"../i18n": { ltr: (value) => value },
"lucide-react": icons,
"../api": {
fmtDuration: (milliseconds) => `duration:${milliseconds}`,
fmtNumber: (value) => String(value),
runDisplayStatus: () => "complete",
timeAgo: (timestamp) => `relative:${timestamp}`,
},
"../tabPreview": { tabOpenGestureHandlers: () => ({}) },
"./StatusBadge": { StatusBadge: ({ status }) => jsx("span", { children: status }) },
"./ui": { Button: ({ children }) => jsx("button", { children }) },
"./ArchiveMenu": { ArchiveMenu: () => null },
};
const source = readFileSync(new URL("../src/components/ExperimentsTable.tsx", import.meta.url), "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
}).outputText;
const exports = {};
new Function("require", "exports", compiled)((name) => {
if (name === "react") return react;
if (name === "react/jsx-runtime") return { jsx, jsxs: jsx, Fragment: Symbol("Fragment") };
return mocks[name] ?? require(name);
}, exports);

function textContent(node) {
if (Array.isArray(node)) return node.map(textContent).join("");
if (typeof node === "string" || typeof node === "number") return String(node);
if (!node || typeof node !== "object") return "";
if (typeof node.type === "function") return textContent(node.type(node.props ?? {}));
return textContent(node.props?.children);
}

function renderTable(run) {
hookIndex = 0;
effects = [];
return exports.ExperimentsTable({
runs: run ? [run] : [],
experiments: [{ id: "experiment-a", title: "Experiment A", slug: "experiment-a", branchName: "main", createdAt: 0 }],
archiveActions: new Map([["experiment-a", {}]]),
onOpen: () => {},
onOpenLogs: () => {},
onOpenCode: () => {},
onArchive: () => {},
onCancel: async () => {},
});
}

test("a live run shows elapsed duration beside its relative time and refreshes each second", () => {
const originalNow = Date.now;
const originalWindow = globalThis.window;
let now = 10_000;
Date.now = () => now;
hookState = [];
try {
const run = {
id: "run-a",
experimentId: "experiment-a",
createdAt: 1_000,
endedAt: null,
status: "running",
cancelRequested: false,
};
const first = textContent(renderTable(run));
assert.match(first, /duration:9000/);
assert.match(first, /relative:1000/);
assert.equal(effects.length, 1);

let tick;
let interval;
globalThis.window = {
setInterval(callback, milliseconds) {
tick = callback;
interval = milliseconds;
return 1;
},
clearInterval() {},
};
effects[0]();
assert.equal(interval, 1_000);

now += 1_000;
tick();
assert.match(textContent(renderTable(run)), /duration:10000/);
} finally {
Date.now = originalNow;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});

test("a completed run keeps its final duration instead of counting past completion", () => {
const originalNow = Date.now;
const originalWindow = globalThis.window;
let intervalStarted = false;
Date.now = () => 100_000;
hookState = [];
globalThis.window = {
setInterval() {
intervalStarted = true;
return 1;
},
clearInterval() {},
};
try {
const run = {
id: "run-a",
experimentId: "experiment-a",
createdAt: 1_000,
endedAt: 10_000,
status: "done",
cancelRequested: false,
};
const rendered = textContent(renderTable(run));
assert.match(rendered, /duration:9000/);
assert.equal(effects.length, 1);
effects[0]();
assert.equal(intervalStarted, false);
} finally {
Date.now = originalNow;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});
Loading