426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
/**
|
|
* Validates that the produced first-run scheduled-task pack is shape-valid
|
|
* per the W1-A `ScheduledTask` contract: required fields are present, every
|
|
* trigger.kind is in the registered set, every priority is in the registered
|
|
* set, every `respectsGlobalPause` is set, every task has an `idempotencyKey`
|
|
* (so replay upserts), and that the customize finalizer's channel-validation
|
|
* fallback path produces an in_app channel + warning.
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
createChannelRegistry,
|
|
registerDefaultChannelPack,
|
|
} from "../src/lifeops/channels/index.ts";
|
|
import type { ConnectorContribution } from "../src/lifeops/connectors/contract.ts";
|
|
import {
|
|
createConnectorRegistry,
|
|
registerConnectorRegistry,
|
|
} from "../src/lifeops/connectors/registry.ts";
|
|
import { installFirstRunChannelInspector } from "../src/lifeops/first-run/channel-inspector.ts";
|
|
import { buildDefaultsPack } from "../src/lifeops/first-run/defaults.ts";
|
|
import {
|
|
parseCategories,
|
|
parseRelationships,
|
|
parseTimeWindow,
|
|
parseTimezone,
|
|
setChannelInspector,
|
|
validateChannel,
|
|
} from "../src/lifeops/first-run/questions.ts";
|
|
import { FirstRunService } from "../src/lifeops/first-run/service.ts";
|
|
import {
|
|
createFirstRunStateStore,
|
|
createOwnerFactStore,
|
|
} from "../src/lifeops/first-run/state.ts";
|
|
import type {
|
|
ScheduledTask,
|
|
ScheduledTaskInput,
|
|
} from "../src/lifeops/wave1-types.ts";
|
|
import { createMinimalRuntimeStub } from "./first-run-helpers.ts";
|
|
|
|
afterEach(() => setChannelInspector(null));
|
|
|
|
const VALID_TRIGGER_KINDS = new Set([
|
|
"once",
|
|
"cron",
|
|
"interval",
|
|
"relative_to_anchor",
|
|
"during_window",
|
|
"event",
|
|
"manual",
|
|
"after_task",
|
|
]);
|
|
const VALID_PRIORITIES = new Set(["low", "medium", "high"]);
|
|
const VALID_KINDS = new Set([
|
|
"reminder",
|
|
"checkin",
|
|
"followup",
|
|
"approval",
|
|
"recap",
|
|
"watcher",
|
|
"output",
|
|
"custom",
|
|
]);
|
|
|
|
/**
|
|
* A runner that mirrors the production runner's idempotency contract: if a
|
|
* task with the same `idempotencyKey` already exists in the live store it is
|
|
* returned as-is (no duplicate). The store is a plain Map the test can delete
|
|
* from to model the user removing a default. This is exactly the surface the
|
|
* seeded-defaults marker must guard: the runner alone would happily re-create a
|
|
* deleted default; the marker is what prevents it.
|
|
*/
|
|
function makeTrackingRunner(tasks: Map<string, ScheduledTask>) {
|
|
let counter = 0;
|
|
return {
|
|
async schedule(input: ScheduledTaskInput): Promise<ScheduledTask> {
|
|
if (input.idempotencyKey) {
|
|
const existing = [...tasks.values()].find(
|
|
(t) => t.idempotencyKey === input.idempotencyKey,
|
|
);
|
|
if (existing) return existing;
|
|
}
|
|
counter += 1;
|
|
const task: ScheduledTask = {
|
|
...input,
|
|
taskId: `track-${counter}`,
|
|
state: { status: "scheduled", followupCount: 0 },
|
|
};
|
|
tasks.set(task.taskId, task);
|
|
return task;
|
|
},
|
|
};
|
|
}
|
|
|
|
function asScheduledTask(input: ScheduledTaskInput): ScheduledTaskInput {
|
|
expect(VALID_KINDS.has(input.kind)).toBe(true);
|
|
expect(VALID_PRIORITIES.has(input.priority)).toBe(true);
|
|
expect(VALID_TRIGGER_KINDS.has(input.trigger.kind)).toBe(true);
|
|
expect(typeof input.respectsGlobalPause).toBe("boolean");
|
|
expect(typeof input.idempotencyKey).toBe("string");
|
|
expect(typeof input.promptInstructions).toBe("string");
|
|
expect(input.promptInstructions.length).toBeGreaterThan(0);
|
|
expect(input.source).toBe("first_run");
|
|
return input;
|
|
}
|
|
|
|
function makeConnectedTelegramConnector(): ConnectorContribution {
|
|
return {
|
|
kind: "telegram",
|
|
capabilities: ["telegram.send"],
|
|
modes: ["local"],
|
|
describe: { label: "Telegram" },
|
|
async start() {},
|
|
async disconnect() {},
|
|
async verify() {
|
|
return true;
|
|
},
|
|
async status() {
|
|
return {
|
|
state: "ok",
|
|
observedAt: "2026-07-06T00:00:00.000Z",
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("first-run config validation", () => {
|
|
it("buildDefaultsPack emits six shape-valid ScheduledTask inputs", () => {
|
|
const pack = buildDefaultsPack({
|
|
morningWindow: { startLocal: "06:30", endLocal: "11:30" },
|
|
timezone: "America/Los_Angeles",
|
|
agentId: "agent-1",
|
|
channel: "in_app",
|
|
});
|
|
expect(pack.length).toBe(6);
|
|
pack.forEach(asScheduledTask);
|
|
// Specific slot assertions
|
|
const slots = new Set(
|
|
pack.map((p) => (p.metadata?.slot ?? null) as string | null),
|
|
);
|
|
expect(slots).toEqual(
|
|
new Set([
|
|
"gm",
|
|
"gn",
|
|
"checkin",
|
|
"morningBrief",
|
|
"weeklyReview",
|
|
"localBackup",
|
|
]),
|
|
);
|
|
const checkin = pack.find((p) => p.metadata?.slot === "checkin");
|
|
expect(checkin?.completionCheck?.kind).toBe("user_replied_within");
|
|
const morningBrief = pack.find((p) => p.metadata?.slot === "morningBrief");
|
|
expect(morningBrief?.trigger.kind).toBe("relative_to_anchor");
|
|
if (morningBrief?.trigger.kind === "relative_to_anchor") {
|
|
expect(morningBrief.trigger.anchorKey).toBe("wake.confirmed");
|
|
}
|
|
// The weekly-review starter ships PAUSED: a manual trigger means it exists
|
|
// and is owner-visible but never fires on its own.
|
|
const weeklyReview = pack.find((p) => p.metadata?.slot === "weeklyReview");
|
|
expect(weeklyReview?.trigger.kind).toBe("manual");
|
|
expect(weeklyReview?.metadata?.pausedByDefault).toBe(true);
|
|
expect(weeklyReview?.ownerVisible).toBe(true);
|
|
const localBackup = pack.find((p) => p.metadata?.slot === "localBackup");
|
|
expect(localBackup?.kind).toBe("output");
|
|
expect(localBackup?.trigger.kind).toBe("cron");
|
|
if (localBackup?.trigger.kind === "cron") {
|
|
expect(localBackup.trigger.expression).toBe("0 */6 * * *");
|
|
expect(localBackup.trigger.tz).toBe("America/Los_Angeles");
|
|
}
|
|
expect(localBackup?.metadata?.systemOperation).toBe("agent.localBackup");
|
|
expect(localBackup?.respectsGlobalPause).toBe(false);
|
|
expect(localBackup?.executionProfile).toBe("bg-heavy-fgs");
|
|
});
|
|
|
|
it("parseTimezone / parseTimeWindow accept valid input and reject garbage", () => {
|
|
expect(parseTimezone("America/New_York")).toBe("America/New_York");
|
|
expect(parseTimezone("")).toBe(null);
|
|
expect(parseTimeWindow({ startLocal: "06:00", endLocal: "11:00" })).toEqual(
|
|
{ startLocal: "06:00", endLocal: "11:00" },
|
|
);
|
|
expect(parseTimeWindow({ startLocal: "11:00", endLocal: "06:00" })).toBe(
|
|
null,
|
|
);
|
|
expect(parseTimeWindow({ startLocal: "25:00", endLocal: "30:00" })).toBe(
|
|
null,
|
|
);
|
|
});
|
|
|
|
it("parseCategories filters to the allowed set", () => {
|
|
expect(
|
|
parseCategories([
|
|
"sleep tracking",
|
|
"ALIENS",
|
|
"follow-ups",
|
|
" inbox triage ",
|
|
]),
|
|
).toEqual(["sleep tracking", "follow-ups", "inbox triage"]);
|
|
});
|
|
|
|
it("parseRelationships shapes user input and bounds at 5 entries", () => {
|
|
const result = parseRelationships(
|
|
Array.from({ length: 8 }, (_, i) => ({
|
|
name: `Person ${i}`,
|
|
cadenceDays: i + 1,
|
|
})),
|
|
);
|
|
expect(result?.length).toBe(5);
|
|
});
|
|
|
|
it("validateChannel falls back to in_app + warning for unconnected channels", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const result = await validateChannel("telegram", runtime);
|
|
expect(result.fallbackToInApp).toBe(true);
|
|
expect(result.warning).toMatch(/fall back/i);
|
|
});
|
|
|
|
it("validateChannel passes a connected channel through cleanly", async () => {
|
|
setChannelInspector({
|
|
isRegistered: () => true,
|
|
isConnected: () => true,
|
|
});
|
|
const runtime = createMinimalRuntimeStub();
|
|
const result = await validateChannel("telegram", runtime);
|
|
expect(result.fallbackToInApp).toBe(false);
|
|
expect(result.warning).toBeUndefined();
|
|
setChannelInspector(null);
|
|
});
|
|
|
|
it("validates a connected Telegram channel through the production registry inspector", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const connectorRegistry = createConnectorRegistry();
|
|
connectorRegistry.register(makeConnectedTelegramConnector());
|
|
registerConnectorRegistry(runtime, connectorRegistry);
|
|
|
|
const channelRegistry = createChannelRegistry();
|
|
registerDefaultChannelPack(channelRegistry, runtime);
|
|
installFirstRunChannelInspector(runtime, channelRegistry);
|
|
|
|
await expect(validateChannel("telegram", runtime)).resolves.toEqual({
|
|
channel: "telegram",
|
|
registered: true,
|
|
connected: true,
|
|
fallbackToInApp: false,
|
|
});
|
|
});
|
|
|
|
it("keeps production channel inspectors isolated per runtime", async () => {
|
|
const connectedRuntime = createMinimalRuntimeStub();
|
|
const connectorRegistry = createConnectorRegistry();
|
|
connectorRegistry.register(makeConnectedTelegramConnector());
|
|
registerConnectorRegistry(connectedRuntime, connectorRegistry);
|
|
|
|
const connectedChannelRegistry = createChannelRegistry();
|
|
registerDefaultChannelPack(connectedChannelRegistry, connectedRuntime);
|
|
installFirstRunChannelInspector(connectedRuntime, connectedChannelRegistry);
|
|
|
|
const disconnectedRuntime = createMinimalRuntimeStub();
|
|
const disconnectedChannelRegistry = createChannelRegistry();
|
|
registerDefaultChannelPack(
|
|
disconnectedChannelRegistry,
|
|
disconnectedRuntime,
|
|
);
|
|
installFirstRunChannelInspector(
|
|
disconnectedRuntime,
|
|
disconnectedChannelRegistry,
|
|
);
|
|
|
|
await expect(
|
|
validateChannel("telegram", connectedRuntime),
|
|
).resolves.toEqual({
|
|
channel: "telegram",
|
|
registered: true,
|
|
connected: true,
|
|
fallbackToInApp: false,
|
|
});
|
|
await expect(
|
|
validateChannel("telegram", disconnectedRuntime),
|
|
).resolves.toMatchObject({
|
|
channel: "telegram",
|
|
registered: true,
|
|
connected: false,
|
|
fallbackToInApp: true,
|
|
});
|
|
});
|
|
|
|
it("rejects an unregistered channel with the right warning", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const result = await validateChannel("morse_code", runtime);
|
|
expect(result.channel).toBe("in_app");
|
|
expect(result.fallbackToInApp).toBe(true);
|
|
expect(result.warning).toMatch(/not registered/i);
|
|
});
|
|
|
|
it("seedDefaultPackOnBoot seeds on an already-initialized runtime that never ran first-run", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const tasks = new Map<string, ScheduledTask>();
|
|
const service = new FirstRunService(runtime, {
|
|
runner: makeTrackingRunner(tasks),
|
|
});
|
|
|
|
// No first-run was ever performed (the lifecycle store is `pending`),
|
|
// yet boot seeding still materializes the full default pack.
|
|
const result = await service.seedDefaultPackOnBoot();
|
|
expect(result.seeded.length).toBe(6);
|
|
expect(result.skipped.length).toBe(0);
|
|
expect(tasks.size).toBe(6);
|
|
|
|
const slots = new Set(
|
|
[...tasks.values()].map((t) => t.metadata?.slot as string),
|
|
);
|
|
expect(slots).toEqual(
|
|
new Set([
|
|
"gm",
|
|
"gn",
|
|
"checkin",
|
|
"morningBrief",
|
|
"weeklyReview",
|
|
"localBackup",
|
|
]),
|
|
);
|
|
|
|
// The weekly-review starter stays paused (manual trigger, never fires
|
|
// on its own).
|
|
const weekly = [...tasks.values()].find(
|
|
(t) => t.metadata?.slot === "weeklyReview",
|
|
);
|
|
expect(weekly?.trigger.kind).toBe("manual");
|
|
expect(weekly?.metadata?.pausedByDefault).toBe(true);
|
|
});
|
|
|
|
it("seedDefaultPackOnBoot is idempotent across two boots — no duplicates", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const tasks = new Map<string, ScheduledTask>();
|
|
|
|
const first = await new FirstRunService(runtime, {
|
|
runner: makeTrackingRunner(tasks),
|
|
}).seedDefaultPackOnBoot();
|
|
expect(first.seeded.length).toBe(6);
|
|
|
|
// Second boot (fresh service instance, same persistent runtime cache):
|
|
// every key is already in the seeded marker, so nothing is re-created.
|
|
const second = await new FirstRunService(runtime, {
|
|
runner: makeTrackingRunner(tasks),
|
|
}).seedDefaultPackOnBoot();
|
|
expect(second.seeded.length).toBe(0);
|
|
expect(second.skipped.length).toBe(6);
|
|
expect(tasks.size).toBe(6);
|
|
});
|
|
|
|
it("seedDefaultPackOnBoot respects user deletion — a deleted default is not recreated", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const tasks = new Map<string, ScheduledTask>();
|
|
|
|
await new FirstRunService(runtime, {
|
|
runner: makeTrackingRunner(tasks),
|
|
}).seedDefaultPackOnBoot();
|
|
expect(tasks.size).toBe(6);
|
|
|
|
// User deletes the weekly-review default out from under the runner.
|
|
const weeklyKey = "lifeops:first-run:default:weekly-review";
|
|
const weekly = [...tasks.values()].find(
|
|
(t) => t.idempotencyKey === weeklyKey,
|
|
);
|
|
if (!weekly) throw new Error("expected weekly-review default to be seeded");
|
|
tasks.delete(weekly.taskId);
|
|
expect(tasks.size).toBe(5);
|
|
|
|
// Next boot must NOT resurrect it — the seeded marker still records the
|
|
// key, so the boot seeder skips it.
|
|
const reboot = await new FirstRunService(runtime, {
|
|
runner: makeTrackingRunner(tasks),
|
|
}).seedDefaultPackOnBoot();
|
|
expect(reboot.seeded.length).toBe(0);
|
|
expect(reboot.skipped).toContain(weeklyKey);
|
|
expect(tasks.size).toBe(5);
|
|
expect(
|
|
[...tasks.values()].some((t) => t.idempotencyKey === weeklyKey),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("first-run then boot does not double-seed (shared per-key marker)", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const tasks = new Map<string, ScheduledTask>();
|
|
|
|
// Fresh install runs first-run defaults, which seeds the pack and records
|
|
// the marker.
|
|
const firstRun = new FirstRunService(runtime, {
|
|
runner: makeTrackingRunner(tasks),
|
|
});
|
|
await firstRun.runDefaultsPath({ wakeTime: "6:30am" });
|
|
expect(tasks.size).toBe(6);
|
|
|
|
// A subsequent boot seeder sees every key already seeded → no double-seed.
|
|
const boot = await new FirstRunService(runtime, {
|
|
runner: makeTrackingRunner(tasks),
|
|
}).seedDefaultPackOnBoot();
|
|
expect(boot.seeded.length).toBe(0);
|
|
expect(boot.skipped.length).toBe(6);
|
|
expect(tasks.size).toBe(6);
|
|
});
|
|
|
|
it("FirstRunService produces shape-valid tasks via the in-memory runner", async () => {
|
|
const runtime = createMinimalRuntimeStub();
|
|
const stateStore = createFirstRunStateStore(runtime);
|
|
const factStore = createOwnerFactStore(runtime);
|
|
const recorded: ScheduledTask[] = [];
|
|
const service = new FirstRunService(runtime, {
|
|
stateStore,
|
|
factStore,
|
|
runner: {
|
|
async schedule(input) {
|
|
asScheduledTask(input);
|
|
const task: ScheduledTask = {
|
|
...input,
|
|
taskId: `t-${recorded.length}`,
|
|
state: { status: "scheduled", followupCount: 0 },
|
|
};
|
|
recorded.push(task);
|
|
return task;
|
|
},
|
|
},
|
|
});
|
|
|
|
// First call without a wake time returns the question.
|
|
const ask = await service.runDefaultsPath({});
|
|
expect(ask.status).toBe("needs_more_input");
|
|
expect(ask.awaitingQuestion).toBe("wakeTime");
|
|
|
|
const done = await service.runDefaultsPath({ wakeTime: "6:30am" });
|
|
expect(done.status).toBe("ok");
|
|
expect(done.scheduledTasks.length).toBe(6);
|
|
expect(recorded.length).toBe(6);
|
|
expect(done.facts.morningWindow?.startLocal).toBe("06:30");
|
|
});
|
|
});
|