mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # lib/crates/fabro-spa/assets/assets/entry-evtq2sja.js # lib/crates/fabro-spa/assets/index.html
This commit is contained in:
commit
afd6994b30
5 changed files with 309 additions and 137 deletions
|
|
@ -1,6 +1,13 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { action, lifecycleActionVisibility, loader } from "./run-detail";
|
||||
import {
|
||||
action,
|
||||
handleLifecycleToastResult,
|
||||
lifecycleActionVisibility,
|
||||
loader,
|
||||
type LifecycleToastState,
|
||||
type RunDetailActionResult,
|
||||
} from "./run-detail";
|
||||
|
||||
type StubFetchEntry = {
|
||||
status: number;
|
||||
|
|
@ -196,3 +203,117 @@ describe("lifecycleActionVisibility", () => {
|
|||
expect(lifecycleActionVisibility("blocked").showBlockedNotice).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleLifecycleToastResult", () => {
|
||||
type PushedToast = { message: string; action?: { label: string; onClick: () => void } };
|
||||
|
||||
function makeToastApi() {
|
||||
const pushed: PushedToast[] = [];
|
||||
const dismissed: string[] = [];
|
||||
return {
|
||||
pushed,
|
||||
dismissed,
|
||||
api: {
|
||||
push: (toast: PushedToast) => {
|
||||
pushed.push(toast);
|
||||
return `toast-${pushed.length}`;
|
||||
},
|
||||
dismiss: (id: string) => {
|
||||
dismissed.push(id);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: LifecycleToastState = {
|
||||
activeArchiveToastId: null,
|
||||
lastProcessed: { cancel: null, archive: null, unarchive: null },
|
||||
};
|
||||
|
||||
test("replaying the same cancel success result does not enqueue a duplicate toast", () => {
|
||||
const { pushed, dismissed, api } = makeToastApi();
|
||||
const result: RunDetailActionResult = {
|
||||
intent: "cancel",
|
||||
ok: true,
|
||||
run: { id: "run-1", status: "failed", status_reason: "cancelled", created_at: "2026-04-20T12:00:00Z" },
|
||||
};
|
||||
|
||||
const firstState = handleLifecycleToastResult("cancel", result, initialState, api);
|
||||
|
||||
expect(pushed).toEqual([{ message: "Run cancelled." }]);
|
||||
expect(firstState.lastProcessed.cancel).toBe(result);
|
||||
|
||||
const replayedState = handleLifecycleToastResult("cancel", result, firstState, api);
|
||||
|
||||
expect(pushed).toHaveLength(1);
|
||||
expect(dismissed).toEqual([]);
|
||||
expect(replayedState).toBe(firstState);
|
||||
});
|
||||
|
||||
test("cancel for non-terminal state reports cancellation as requested", () => {
|
||||
const { pushed, api } = makeToastApi();
|
||||
const result: RunDetailActionResult = {
|
||||
intent: "cancel",
|
||||
ok: true,
|
||||
run: { id: "run-1", status: "running", created_at: "2026-04-20T12:00:00Z" },
|
||||
};
|
||||
|
||||
handleLifecycleToastResult("cancel", result, initialState, api);
|
||||
|
||||
expect(pushed).toEqual([{ message: "Cancellation requested." }]);
|
||||
});
|
||||
|
||||
test("replaying the same archive success result does not enqueue a duplicate toast", () => {
|
||||
const { pushed, dismissed, api } = makeToastApi();
|
||||
let unarchiveClicks = 0;
|
||||
const result: RunDetailActionResult = {
|
||||
intent: "archive",
|
||||
ok: true,
|
||||
run: { id: "run-1", status: "archived", created_at: "2026-04-20T12:00:00Z" },
|
||||
};
|
||||
|
||||
const firstState = handleLifecycleToastResult("archive", result, initialState, api, () => {
|
||||
unarchiveClicks += 1;
|
||||
});
|
||||
|
||||
expect(pushed).toHaveLength(1);
|
||||
expect(pushed[0]?.message).toBe("Run archived.");
|
||||
expect(pushed[0]?.action?.label).toBe("Unarchive");
|
||||
pushed[0]?.action?.onClick();
|
||||
expect(unarchiveClicks).toBe(1);
|
||||
expect(firstState.activeArchiveToastId).toBe("toast-1");
|
||||
|
||||
const replayedState = handleLifecycleToastResult("archive", result, firstState, api, () => {
|
||||
unarchiveClicks += 1;
|
||||
});
|
||||
|
||||
expect(pushed).toHaveLength(1);
|
||||
expect(replayedState).toBe(firstState);
|
||||
expect(dismissed).toEqual([]);
|
||||
});
|
||||
|
||||
test("successful unarchive dismisses the active archive toast before showing restore feedback", () => {
|
||||
const { pushed, dismissed, api } = makeToastApi();
|
||||
const result: RunDetailActionResult = {
|
||||
intent: "unarchive",
|
||||
ok: true,
|
||||
run: { id: "run-1", status: "succeeded", created_at: "2026-04-20T12:00:00Z" },
|
||||
};
|
||||
const stateWithActiveToast: LifecycleToastState = {
|
||||
activeArchiveToastId: "toast-9",
|
||||
lastProcessed: { cancel: null, archive: null, unarchive: null },
|
||||
};
|
||||
|
||||
const nextState = handleLifecycleToastResult("unarchive", result, stateWithActiveToast, api);
|
||||
|
||||
expect(dismissed).toEqual(["toast-9"]);
|
||||
expect(pushed).toEqual([{ message: "Run restored." }]);
|
||||
expect(nextState.activeArchiveToastId).toBeNull();
|
||||
|
||||
const replayedState = handleLifecycleToastResult("unarchive", result, nextState, api);
|
||||
|
||||
expect(dismissed).toEqual(["toast-9"]);
|
||||
expect(pushed).toEqual([{ message: "Run restored." }]);
|
||||
expect(replayedState).toBe(nextState);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, Outlet, useFetcher, useLocation } from "react-router";
|
||||
import type {
|
||||
|
|
@ -95,6 +95,18 @@ type LifecycleActionResult =
|
|||
|
||||
export type RunDetailActionResult = PreviewActionResult | LifecycleActionResult;
|
||||
|
||||
export interface LifecycleToastState {
|
||||
activeArchiveToastId: string | null;
|
||||
lastProcessed: Record<LifecycleAction, RunDetailActionResult | null>;
|
||||
}
|
||||
|
||||
type ToastApi = Pick<ReturnType<typeof useToast>, "push" | "dismiss">;
|
||||
|
||||
const INITIAL_LIFECYCLE_TOAST_STATE: LifecycleToastState = {
|
||||
activeArchiveToastId: null,
|
||||
lastProcessed: { cancel: null, archive: null, unarchive: null },
|
||||
};
|
||||
|
||||
export function lifecycleActionVisibility(status: string | null | undefined) {
|
||||
return {
|
||||
showPrimaryCancel: canCancel(status),
|
||||
|
|
@ -175,9 +187,10 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai
|
|||
const cancelFetcher = useFetcher<RunDetailActionResult>();
|
||||
const archiveFetcher = useFetcher<RunDetailActionResult>();
|
||||
const unarchiveFetcher = useFetcher<RunDetailActionResult>();
|
||||
const { push } = useToast();
|
||||
const { push, dismiss } = useToast();
|
||||
const demoMode = useDemoMode();
|
||||
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
|
||||
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
|
||||
|
||||
useRunEventSource(run?.id ?? undefined, {
|
||||
allowlist: RUN_DETAIL_EVENTS,
|
||||
|
|
@ -191,44 +204,32 @@ export default function RunDetail({ loaderData, params }: { loaderData: RunDetai
|
|||
}, [previewFetcher.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const result = cancelFetcher.data;
|
||||
if (!result || result.intent !== "cancel") return;
|
||||
if (isLifecycleActionFailure(result)) {
|
||||
push({ message: mapError(result.error, "cancel"), tone: "error" });
|
||||
return;
|
||||
}
|
||||
push({
|
||||
message: isTerminalCancelledRun(result.run)
|
||||
? "Run cancelled."
|
||||
: "Cancellation requested.",
|
||||
});
|
||||
}, [cancelFetcher.data, push]);
|
||||
lifecycleToastStateRef.current = handleLifecycleToastResult(
|
||||
"cancel",
|
||||
cancelFetcher.data,
|
||||
lifecycleToastStateRef.current,
|
||||
{ push, dismiss },
|
||||
);
|
||||
}, [cancelFetcher.data, dismiss, push]);
|
||||
|
||||
useEffect(() => {
|
||||
const result = archiveFetcher.data;
|
||||
if (!result || result.intent !== "archive") return;
|
||||
if (isLifecycleActionFailure(result)) {
|
||||
push({ message: mapError(result.error, "archive"), tone: "error" });
|
||||
return;
|
||||
}
|
||||
push({
|
||||
message: "Run archived.",
|
||||
action: {
|
||||
label: "Unarchive",
|
||||
onClick: () => submitIntent(unarchiveFetcher, "unarchive"),
|
||||
},
|
||||
});
|
||||
}, [archiveFetcher, archiveFetcher.data, push, unarchiveFetcher]);
|
||||
lifecycleToastStateRef.current = handleLifecycleToastResult(
|
||||
"archive",
|
||||
archiveFetcher.data,
|
||||
lifecycleToastStateRef.current,
|
||||
{ push, dismiss },
|
||||
() => submitIntent(unarchiveFetcher, "unarchive"),
|
||||
);
|
||||
}, [archiveFetcher.data, dismiss, push, unarchiveFetcher]);
|
||||
|
||||
useEffect(() => {
|
||||
const result = unarchiveFetcher.data;
|
||||
if (!result || result.intent !== "unarchive") return;
|
||||
if (isLifecycleActionFailure(result)) {
|
||||
push({ message: mapError(result.error, "unarchive"), tone: "error" });
|
||||
return;
|
||||
}
|
||||
push({ message: "Run restored." });
|
||||
}, [push, unarchiveFetcher.data]);
|
||||
lifecycleToastStateRef.current = handleLifecycleToastResult(
|
||||
"unarchive",
|
||||
unarchiveFetcher.data,
|
||||
lifecycleToastStateRef.current,
|
||||
{ push, dismiss },
|
||||
);
|
||||
}, [dismiss, push, unarchiveFetcher.data]);
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
|
|
@ -458,6 +459,51 @@ function isLifecycleActionFailure(
|
|||
return value.ok === false;
|
||||
}
|
||||
|
||||
export function handleLifecycleToastResult(
|
||||
intent: LifecycleAction,
|
||||
result: RunDetailActionResult | undefined,
|
||||
state: LifecycleToastState,
|
||||
toastApi: ToastApi,
|
||||
onUnarchive?: () => void,
|
||||
): LifecycleToastState {
|
||||
if (!result || result.intent !== intent) return state;
|
||||
if (state.lastProcessed[intent] === result) return state;
|
||||
|
||||
const nextState: LifecycleToastState = {
|
||||
...state,
|
||||
lastProcessed: { ...state.lastProcessed, [intent]: result },
|
||||
};
|
||||
|
||||
if (isLifecycleActionFailure(result)) {
|
||||
toastApi.push({ message: mapError(result.error, intent), tone: "error" });
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (intent === "cancel") {
|
||||
toastApi.push({
|
||||
message: isTerminalCancelledRun(result.run) ? "Run cancelled." : "Cancellation requested.",
|
||||
});
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (state.activeArchiveToastId) {
|
||||
toastApi.dismiss(state.activeArchiveToastId);
|
||||
}
|
||||
|
||||
if (intent === "archive") {
|
||||
return {
|
||||
...nextState,
|
||||
activeArchiveToastId: toastApi.push({
|
||||
message: "Run archived.",
|
||||
action: onUnarchive ? { label: "Unarchive", onClick: onUnarchive } : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
toastApi.push({ message: "Run restored." });
|
||||
return { ...nextState, activeArchiveToastId: null };
|
||||
}
|
||||
|
||||
function submitIntent(
|
||||
fetcher: { submit: (target: FormData, options: { method: "post" }) => void },
|
||||
intent: LifecycleAction,
|
||||
|
|
|
|||
|
|
@ -146,12 +146,17 @@ pub fn sigusr2(pid: u32) {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "Tests use sync std::io::BufReader to read a short-lived helper's stdout synchronously."
|
||||
)]
|
||||
mod tests {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{process_exists, process_group_alive, process_running};
|
||||
use crate::pre_exec::pre_exec_setpgid;
|
||||
|
||||
#[test]
|
||||
fn process_running_returns_true_for_current_process() {
|
||||
|
|
@ -199,7 +204,7 @@ mod tests {
|
|||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
crate::pre_exec::pre_exec_setpgid(&mut child);
|
||||
pre_exec_setpgid(&mut child);
|
||||
let mut child = child.spawn().expect("group leader should spawn");
|
||||
let pgid = child.id();
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -58,7 +58,7 @@
|
|||
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
||||
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
||||
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
||||
<script type="module" src="/assets/entry-znhdzrrr.js"></script>
|
||||
<script type="module" src="/assets/entry-b79sap7r.js"></script>
|
||||
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
|
||||
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
|
||||
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue