swarm-ui: the refresh picker shows a spinner in place of its clock while its page is fetching
usePolled reports when a request is in flight (through a new onBusy of the poller) and holds it on for at least 0.4 seconds, so a quick answer still reads as a refresh. The picker swaps its clock for the spinner, in the same box so its width stays, and the spinner stands still where motion is off. Every page with a picker passes it; a page with several polled requests shows it while any of them runs. The Jobs page fetches through shared components and has none yet.
This commit is contained in:
parent
89bbeb9757
commit
47c447409b
16 changed files with 296 additions and 23 deletions
|
|
@ -75,7 +75,11 @@ function AlertsBody() {
|
|||
const [showAll, setShowAll] = useState(false);
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: alerts, error } = usePolled(intervalMs, (signal) =>
|
||||
const {
|
||||
data: alerts,
|
||||
error,
|
||||
refreshing,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<Alert[]>("/api/alerts", signal),
|
||||
);
|
||||
const listed = alerts ? listedAlerts(alerts, showAll) : null;
|
||||
|
|
@ -96,6 +100,7 @@ function AlertsBody() {
|
|||
id="alerts-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
|
|
|
|||
|
|
@ -510,6 +510,14 @@ export function ComponentsPage() {
|
|||
<Sample label="editable">
|
||||
<RefreshIntervalPickerSample />
|
||||
</Sample>
|
||||
<Sample label="refreshing">
|
||||
<RefreshIntervalPicker
|
||||
id="sample-refresh-interval-busy"
|
||||
value={30_000}
|
||||
onChange={() => undefined}
|
||||
refreshing
|
||||
/>
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="TextField">
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export function AgentsPage() {
|
|||
const {
|
||||
data: rows,
|
||||
error,
|
||||
refreshing,
|
||||
setData: setRows,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<AgentRow[]>("/api/agents/status", signal),
|
||||
|
|
@ -131,6 +132,7 @@ export function AgentsPage() {
|
|||
id="agents-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,11 @@ function storedFilter(): AgentFilter {
|
|||
export function BroadcastComposer() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: agents, error: loadError } = usePolled(intervalMs, (signal) =>
|
||||
const {
|
||||
data: agents,
|
||||
error: loadError,
|
||||
refreshing,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<BroadcastAgent[]>("/api/agents/status", signal),
|
||||
);
|
||||
const [filter, setFilterState] = useState<AgentFilter>(storedFilter);
|
||||
|
|
@ -173,6 +177,7 @@ export function BroadcastComposer() {
|
|||
id="broadcast-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -96,9 +96,11 @@ function BuildsBody() {
|
|||
const wanted = wantedRun(useSearch());
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: runs, error } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<BuildRun[]>(RUNS, signal),
|
||||
);
|
||||
const {
|
||||
data: runs,
|
||||
error,
|
||||
refreshing,
|
||||
} = usePolled(intervalMs, (signal) => fetchJson<BuildRun[]>(RUNS, signal));
|
||||
// Which cards are open. Survives a refresh, so a list that updates under
|
||||
// you does not fold the card you are reading. A linked build starts open.
|
||||
const [open, setOpen] = useState<ReadonlySet<string>>(
|
||||
|
|
@ -134,6 +136,7 @@ function BuildsBody() {
|
|||
id="builds-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -41,18 +41,24 @@ export function NotificationRouter() {
|
|||
const {
|
||||
data: view,
|
||||
error,
|
||||
refreshing: flowsRefreshing,
|
||||
refresh,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<FlowsView>("/api/notify/flows", signal),
|
||||
);
|
||||
// The router's own answers. Either is 503 while the running router predates
|
||||
// it, which the page says in place of the section.
|
||||
const { data: dry, error: dryError } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<DryRun>(DRY_RUN_URL, signal),
|
||||
);
|
||||
const { data: deliveries, error: deliveriesError } = usePolled(
|
||||
intervalMs,
|
||||
(signal) => fetchJson<DeliveriesView>(DELIVERIES_URL, signal),
|
||||
const {
|
||||
data: dry,
|
||||
error: dryError,
|
||||
refreshing: dryRefreshing,
|
||||
} = usePolled(intervalMs, (signal) => fetchJson<DryRun>(DRY_RUN_URL, signal));
|
||||
const {
|
||||
data: deliveries,
|
||||
error: deliveriesError,
|
||||
refreshing: deliveriesRefreshing,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<DeliveriesView>(DELIVERIES_URL, signal),
|
||||
);
|
||||
// A failed poll keeps the last answer; the counts are shown only while the
|
||||
// latest poll worked.
|
||||
|
|
@ -102,6 +108,7 @@ export function NotificationRouter() {
|
|||
id="notifications-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={flowsRefreshing || dryRefreshing || deliveriesRefreshing}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => draftFrom()}>
|
||||
create flow
|
||||
|
|
|
|||
|
|
@ -93,7 +93,11 @@ function RunCard({
|
|||
export function ReviewRuns() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: runs, error } = usePolled(intervalMs, (signal) =>
|
||||
const {
|
||||
data: runs,
|
||||
error,
|
||||
refreshing,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<ClassifierRun[]>(RUNS, signal),
|
||||
);
|
||||
// Which cards are expanded. Survives a refresh, so a list that updates
|
||||
|
|
@ -117,6 +121,7 @@ export function ReviewRuns() {
|
|||
id="review-runs-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ function SchedulesBody() {
|
|||
const {
|
||||
data: schedules,
|
||||
error,
|
||||
refreshing: schedulesRefreshing,
|
||||
refresh,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<Schedule[]>("/api/schedules", signal),
|
||||
|
|
@ -72,7 +73,11 @@ function SchedulesBody() {
|
|||
// lists, polled on the same cadence so an agent created after this page
|
||||
// opened shows up. A failed load does not block the page, the cards do not
|
||||
// need it; the form says the list could not load.
|
||||
const { data: roster, error: rosterError } = usePolled(intervalMs, (signal) =>
|
||||
const {
|
||||
data: roster,
|
||||
error: rosterError,
|
||||
refreshing: rosterRefreshing,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<AgentRow[]>("/api/agents/status", signal),
|
||||
);
|
||||
const agentNames = useMemo(() => agentNamesFrom(roster), [roster]);
|
||||
|
|
@ -159,6 +164,7 @@ function SchedulesBody() {
|
|||
id="schedules-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={schedulesRefreshing || rosterRefreshing}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
|
|
|
|||
|
|
@ -116,7 +116,11 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
|||
function HiveRoster() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: hives, error } = usePolled(intervalMs, (signal) =>
|
||||
const {
|
||||
data: hives,
|
||||
error,
|
||||
refreshing,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<HiveStatus[]>("/api/hives/status", signal),
|
||||
);
|
||||
|
||||
|
|
@ -127,6 +131,7 @@ function HiveRoster() {
|
|||
id="hives-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
|
|
|
|||
60
frontend/packages/swarm-ui/src/ui/polled/busy-hold.ts
Normal file
60
frontend/packages/swarm-ui/src/ui/polled/busy-hold.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// What a refresh indicator shows, from whether a request is in flight: it turns
|
||||
// on at once and, once the request is over, stays on for the rest of `minMs`
|
||||
// since it came on, so an answer that takes a blink still shows as a refresh
|
||||
// instead of a flash. Free of Preact (and of the real clock, for the tests).
|
||||
export const MIN_BUSY_MS = 400;
|
||||
|
||||
export function createBusyHold(opts: {
|
||||
/** Called with what to show, only when it changes. */
|
||||
show: (shown: boolean) => void;
|
||||
minMs?: number;
|
||||
now?: () => number;
|
||||
after?: (fn: () => void, ms: number) => unknown;
|
||||
cancel?: (timer: unknown) => void;
|
||||
}): { busy(busy: boolean): void; stop(): void } {
|
||||
const minMs = opts.minMs ?? MIN_BUSY_MS;
|
||||
const now = opts.now ?? Date.now;
|
||||
const after = opts.after ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const cancel =
|
||||
opts.cancel ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
|
||||
let shown = false;
|
||||
let since = 0;
|
||||
let timer: unknown = null;
|
||||
|
||||
function set(next: boolean) {
|
||||
if (next === shown) return;
|
||||
shown = next;
|
||||
opts.show(next);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (timer === null) return;
|
||||
cancel(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
return {
|
||||
busy(busy) {
|
||||
if (busy) {
|
||||
clear();
|
||||
if (!shown) since = now();
|
||||
set(true);
|
||||
return;
|
||||
}
|
||||
if (!shown) return;
|
||||
const left = minMs - (now() - since);
|
||||
if (left <= 0) {
|
||||
set(false);
|
||||
return;
|
||||
}
|
||||
clear();
|
||||
timer = after(() => {
|
||||
timer = null;
|
||||
set(false);
|
||||
}, left);
|
||||
},
|
||||
stop() {
|
||||
clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
// request in flight and asks again, for a refresh that follows a change.
|
||||
// `stop` drops the request in flight. An answer from a dropped request never
|
||||
// reaches `onData` or `onError`, so an older response cannot overwrite a
|
||||
// newer one or land on a page that is gone.
|
||||
// newer one or land on a page that is gone. `onBusy` tells when a request goes
|
||||
// in flight and when none is any more, however it ended, so a page can show
|
||||
// that it is refreshing.
|
||||
export interface Poller {
|
||||
tick(): void;
|
||||
refetch(): void;
|
||||
|
|
@ -15,29 +17,35 @@ export function createPoller<T>(opts: {
|
|||
fetch: (signal: AbortSignal) => Promise<T>;
|
||||
onData: (data: T) => void;
|
||||
onError: (error: unknown) => void;
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}): Poller {
|
||||
let current: { controller: AbortController } | null = null;
|
||||
|
||||
function start() {
|
||||
const run = { controller: new AbortController() };
|
||||
current = run;
|
||||
opts.onBusy?.(true);
|
||||
opts.fetch(run.controller.signal).then(
|
||||
(data) => {
|
||||
if (current !== run) return;
|
||||
current = null;
|
||||
opts.onBusy?.(false);
|
||||
opts.onData(data);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (current !== run) return;
|
||||
current = null;
|
||||
opts.onBusy?.(false);
|
||||
opts.onError(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
current?.controller.abort();
|
||||
if (current === null) return;
|
||||
current.controller.abort();
|
||||
current = null;
|
||||
opts.onBusy?.(false);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// A resource refetched on a timer: `{ data, error, refresh, setData }`.
|
||||
// A resource refetched on a timer: `{ data, error, refreshing, refresh, setData }`.
|
||||
// `data` is `null` until the first answer; a failed fetch sets `error` and
|
||||
// keeps the last good `data`; the next good answer clears `error`. The
|
||||
// request discipline (skip a tick while one is in flight, drop answers that
|
||||
|
|
@ -8,12 +8,15 @@
|
|||
// does that for a GET. It may change between renders: the latest one runs.
|
||||
// `refresh` refetches now, dropping a request in flight, for after a change
|
||||
// the page made. `setData` patches what is shown without a request.
|
||||
// `refreshing` is true while a request is in flight, and for a short minimum
|
||||
// after it starts (`createBusyHold`), for the refresh picker's spinner.
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import {
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../refresh-interval/RefreshInterval.js";
|
||||
import { createBusyHold } from "./busy-hold.js";
|
||||
import { createPoller, type Poller } from "./poller.js";
|
||||
|
||||
/** A failed request, carrying the problem the API answered with. */
|
||||
|
|
@ -43,15 +46,22 @@ export function usePolled<T>(
|
|||
): {
|
||||
data: T | null;
|
||||
error: ProblemDetails | null;
|
||||
refreshing: boolean;
|
||||
refresh: () => void;
|
||||
setData: (update: (prev: T | null) => T | null) => void;
|
||||
} {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetcherRef = useRef(fetcher);
|
||||
fetcherRef.current = fetcher;
|
||||
const pollerRef = useRef<Poller | null>(null);
|
||||
const holdRef = useRef<ReturnType<typeof createBusyHold> | null>(null);
|
||||
if (holdRef.current === null) {
|
||||
holdRef.current = createBusyHold({ show: setRefreshing });
|
||||
}
|
||||
const hold = holdRef.current;
|
||||
if (pollerRef.current === null) {
|
||||
pollerRef.current = createPoller<T>({
|
||||
fetch: (signal) => fetcherRef.current(signal),
|
||||
|
|
@ -60,12 +70,19 @@ export function usePolled<T>(
|
|||
setError(null);
|
||||
},
|
||||
onError: (e) => setError(toProblem(e)),
|
||||
onBusy: (busy) => hold.busy(busy),
|
||||
});
|
||||
}
|
||||
const poller = pollerRef.current;
|
||||
|
||||
useEffect(() => () => poller.stop(), [poller]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
poller.stop();
|
||||
hold.stop();
|
||||
},
|
||||
[poller, hold],
|
||||
);
|
||||
useRefreshInterval(intervalMs, () => poller.tick());
|
||||
|
||||
return { data, error, refresh: () => poller.refetch(), setData };
|
||||
return { data, error, refreshing, refresh: () => poller.refetch(), setData };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,40 @@
|
|||
color: inherit;
|
||||
}
|
||||
.ui-refresh-picker-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The spinner takes the clock's place: the same box, so the picker keeps its
|
||||
width. Motion follows the page's setting, the same three rules the page
|
||||
switch uses; where motion is off it is a static arc. */
|
||||
.ui-refresh-picker-spinner {
|
||||
box-sizing: border-box;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: ui-refresh-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ui-refresh-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-refresh-picker-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion="reduce"] .ui-refresh-picker-spinner {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion="allow"] .ui-refresh-picker-spinner {
|
||||
animation: ui-refresh-spin 0.8s linear infinite;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,16 +43,23 @@ export function RefreshIntervalPicker({
|
|||
id,
|
||||
value,
|
||||
onChange,
|
||||
refreshing = false,
|
||||
}: {
|
||||
id: string;
|
||||
value: RefreshIntervalMs;
|
||||
onChange: (value: RefreshIntervalMs) => void;
|
||||
/** A request is in flight: a spinner takes the clock's place, so the width stays. */
|
||||
refreshing?: boolean;
|
||||
}) {
|
||||
const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0];
|
||||
return (
|
||||
<div class="ui-refresh-picker">
|
||||
<span class="ui-refresh-picker-icon" aria-hidden="true">
|
||||
🕐
|
||||
<div class="ui-refresh-picker" aria-busy={refreshing}>
|
||||
<span
|
||||
class="ui-refresh-picker-icon"
|
||||
aria-hidden="true"
|
||||
title={refreshing ? "refreshing" : undefined}
|
||||
>
|
||||
{refreshing ? <span class="ui-refresh-picker-spinner" /> : "🕐"}
|
||||
</span>
|
||||
<Listbox
|
||||
id={id}
|
||||
|
|
|
|||
77
frontend/packages/swarm-ui/test/busy-hold.test.mjs
Normal file
77
frontend/packages/swarm-ui/test/busy-hold.test.mjs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// What the refresh spinner shows: on at once, off only after a minimum time.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createBusyHold } from "../src/ui/polled/busy-hold.ts";
|
||||
|
||||
// A clock and a timer the test moves by hand.
|
||||
function rig() {
|
||||
let t = 0;
|
||||
const timers = [];
|
||||
const shown = [];
|
||||
const hold = createBusyHold({
|
||||
show: (s) => shown.push(s),
|
||||
minMs: 400,
|
||||
now: () => t,
|
||||
after: (fn, ms) => {
|
||||
const timer = { fn, at: t + ms, live: true };
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
},
|
||||
cancel: (timer) => {
|
||||
timer.live = false;
|
||||
},
|
||||
});
|
||||
const advance = (ms) => {
|
||||
t += ms;
|
||||
for (const timer of timers) {
|
||||
if (timer.live && timer.at <= t) {
|
||||
timer.live = false;
|
||||
timer.fn();
|
||||
}
|
||||
}
|
||||
};
|
||||
return { hold, shown, advance };
|
||||
}
|
||||
|
||||
test("it turns on at once and, for a quick answer, off only after the minimum", () => {
|
||||
const { hold, shown, advance } = rig();
|
||||
hold.busy(true);
|
||||
assert.deepEqual(shown, [true]);
|
||||
advance(50);
|
||||
hold.busy(false);
|
||||
assert.deepEqual(shown, [true]);
|
||||
advance(349);
|
||||
assert.deepEqual(shown, [true]);
|
||||
advance(1);
|
||||
assert.deepEqual(shown, [true, false]);
|
||||
});
|
||||
|
||||
test("a slow answer turns it off as soon as it arrives", () => {
|
||||
const { hold, shown, advance } = rig();
|
||||
hold.busy(true);
|
||||
advance(900);
|
||||
hold.busy(false);
|
||||
assert.deepEqual(shown, [true, false]);
|
||||
});
|
||||
|
||||
test("a new request while it is held keeps it on and cancels the pending off", () => {
|
||||
const { hold, shown, advance } = rig();
|
||||
hold.busy(true);
|
||||
advance(100);
|
||||
hold.busy(false);
|
||||
hold.busy(true);
|
||||
advance(1000);
|
||||
assert.deepEqual(shown, [true]);
|
||||
hold.busy(false);
|
||||
assert.deepEqual(shown, [true, false]);
|
||||
});
|
||||
|
||||
test("stop drops a pending off, and busy(false) with nothing shown does nothing", () => {
|
||||
const { hold, shown, advance } = rig();
|
||||
hold.busy(false);
|
||||
hold.busy(true);
|
||||
hold.busy(false);
|
||||
hold.stop();
|
||||
advance(1000);
|
||||
assert.deepEqual(shown, [true]);
|
||||
});
|
||||
|
|
@ -92,3 +92,25 @@ test("the answer of a dropped request that fails is not reported", async () => {
|
|||
await flush();
|
||||
assert.deepEqual(sink.seen.error, []);
|
||||
});
|
||||
|
||||
test("onBusy follows a request in flight, however it ends", async () => {
|
||||
const { calls, fetch } = manualFetch();
|
||||
const sink = collect();
|
||||
const busy = [];
|
||||
const poller = createPoller({ fetch, ...sink, onBusy: (b) => busy.push(b) });
|
||||
poller.tick();
|
||||
poller.tick();
|
||||
assert.deepEqual(busy, [true]);
|
||||
calls[0].resolve("one");
|
||||
await flush();
|
||||
assert.deepEqual(busy, [true, false]);
|
||||
poller.tick();
|
||||
calls[1].reject(new Error("boom"));
|
||||
await flush();
|
||||
assert.deepEqual(busy, [true, false, true, false]);
|
||||
poller.tick();
|
||||
poller.stop();
|
||||
assert.deepEqual(busy, [true, false, true, false, true, false]);
|
||||
poller.stop();
|
||||
assert.equal(busy.length, 6);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue