t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Cloudflare/Stream/LiveInputOutput.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

304 lines
10 KiB
TypeScript

import * as Cloudflare from "@/Cloudflare";
import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
import * as Provider from "@/Provider";
import * as Test from "@/Test/Alchemy";
import * as stream from "@distilled.cloud/cloudflare/stream";
import { expect } from "alchemy-test";
import * as Effect from "effect/Effect";
import { MinimumLogLevel } from "effect/References";
import * as Schedule from "effect/Schedule";
const { test } = Test.make({ providers: Cloudflare.providers() });
const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);
// The scoped API token the test harness mints propagates eventually-
// consistently across Cloudflare's edge — ride out 403 blips
// (`Forbidden`, declared in the distilled error union) on the test's
// own out-of-band verification calls.
const listOutputs = (accountId: string, liveInputId: string) =>
stream
.listLiveInputOutputs({ accountId, liveInputIdentifier: liveInputId })
.pipe(
Effect.retry({
while: (e) => e._tag === "Forbidden",
schedule: Schedule.exponential("500 millis"),
times: 8,
}),
);
const findOutput = (accountId: string, liveInputId: string, outputId: string) =>
listOutputs(accountId, liveInputId).pipe(
Effect.map((page) => page.result.find((o) => o.uid === outputId)),
);
const expectGone = (accountId: string, liveInputId: string, outputId: string) =>
findOutput(accountId, liveInputId, outputId).pipe(
Effect.flatMap((found) =>
found === undefined
? Effect.void
: Effect.fail({ _tag: "OutputNotDeleted" } as const),
),
// The parent live input being gone counts as the output being gone.
Effect.catchTag("LiveInputNotFound", () => Effect.void),
Effect.retry({
while: (e) => e._tag === "OutputNotDeleted",
schedule: Schedule.max([
Schedule.exponential("500 millis"),
Schedule.recurs(10),
]),
}),
);
test.provider(
"create, toggle enabled in place, replace destination, and delete",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
yield* stack.destroy();
const deployOutput = (props: {
url: string;
streamKey: string;
enabled?: boolean;
}) =>
Effect.gen(function* () {
const input = yield* Cloudflare.Stream.LiveInput("RestreamInput", {
meta: { name: "alchemy-stream-output-input" },
});
const output = yield* Cloudflare.Stream.LiveInputOutput("Restream", {
liveInputId: input.liveInputId,
...props,
});
return { input, output };
});
const created = yield* stack.deploy(
deployOutput({
url: "rtmps://a.rtmps.youtube.com/live2",
streamKey: "alchemy-test-stream-key",
}),
);
expect(created.output.outputId).toBeTruthy();
expect(created.output.liveInputId).toEqual(created.input.liveInputId);
expect(created.output.accountId).toEqual(accountId);
expect(created.output.url).toEqual("rtmps://a.rtmps.youtube.com/live2");
expect(created.output.streamKey).toEqual("alchemy-test-stream-key");
expect(created.output.enabled).toBe(true);
// Out-of-band verify via distilled.
const observed = yield* findOutput(
accountId,
created.input.liveInputId,
created.output.outputId,
);
expect(observed?.uid).toEqual(created.output.outputId);
expect(observed?.enabled).toBe(true);
// Toggle `enabled` — updates in place, same uid.
const disabled = yield* stack.deploy(
deployOutput({
url: "rtmps://a.rtmps.youtube.com/live2",
streamKey: "alchemy-test-stream-key",
enabled: false,
}),
);
expect(disabled.output.outputId).toEqual(created.output.outputId);
expect(disabled.output.enabled).toBe(false);
const observedDisabled = yield* findOutput(
accountId,
created.input.liveInputId,
created.output.outputId,
);
expect(observedDisabled?.enabled).toBe(false);
// Redeploying identical props is a no-op (still the same output).
const noop = yield* stack.deploy(
deployOutput({
url: "rtmps://a.rtmps.youtube.com/live2",
streamKey: "alchemy-test-stream-key",
enabled: false,
}),
);
expect(noop.output.outputId).toEqual(created.output.outputId);
// Changing the destination (url/streamKey) replaces the output.
const replaced = yield* stack.deploy(
deployOutput({
url: "rtmps://b.rtmps.youtube.com/live2?backup=1",
streamKey: "alchemy-test-stream-key-v2",
}),
);
expect(replaced.output.outputId).not.toEqual(created.output.outputId);
expect(replaced.output.url).toEqual(
"rtmps://b.rtmps.youtube.com/live2?backup=1",
);
expect(replaced.output.enabled).toBe(true);
// The replaced (old) output is gone; the new one exists.
yield* expectGone(
accountId,
created.input.liveInputId,
created.output.outputId,
);
const observedReplaced = yield* findOutput(
accountId,
replaced.input.liveInputId,
replaced.output.outputId,
);
expect(observedReplaced?.uid).toEqual(replaced.output.outputId);
yield* stack.destroy();
yield* expectGone(
accountId,
replaced.input.liveInputId,
replaced.output.outputId,
);
}).pipe(logLevel),
{
tags: ["provider:cloudflare", "provider:cloudflare:stream", "live"],
timeout: 120_000,
},
);
test.provider(
"recreates after out-of-band delete",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
yield* stack.destroy();
const deployOutput = (enabled?: boolean) =>
Effect.gen(function* () {
const input = yield* Cloudflare.Stream.LiveInput("HealOutputInput", {
meta: { name: "alchemy-stream-output-heal-input" },
});
const output = yield* Cloudflare.Stream.LiveInputOutput(
"HealOutput",
{
liveInputId: input.liveInputId,
url: "rtmps://a.rtmps.youtube.com/live2",
streamKey: "alchemy-heal-stream-key",
enabled,
},
);
return { input, output };
});
const created = yield* stack.deploy(deployOutput());
// Delete the output out-of-band. A redeploy with identical props is
// a planner no-op, so toggle `enabled` to force reconcile — it must
// observe the output as missing and recreate it instead of failing.
yield* stream
.deleteLiveInputOutput({
accountId,
liveInputIdentifier: created.input.liveInputId,
outputIdentifier: created.output.outputId,
})
.pipe(
Effect.retry({
while: (e) => e._tag === "Forbidden",
schedule: Schedule.exponential("500 millis"),
times: 8,
}),
);
const healed = yield* stack.deploy(deployOutput(false));
expect(healed.output.outputId).not.toEqual(created.output.outputId);
expect(healed.output.enabled).toBe(false);
yield* stack.destroy();
yield* expectGone(
accountId,
healed.input.liveInputId,
healed.output.outputId,
);
}).pipe(logLevel),
{
tags: ["provider:cloudflare", "provider:cloudflare:stream", "live"],
timeout: 120_000,
},
);
// `list()` enumerates every live input on the account (via
// `stream.listLiveInputs`) and then lists each input's outputs. The
// distilled `listLiveInputs` response schema is currently wrong: it
// decodes `result` as an object `{ liveInputs?, range?, total? }`, but
// Cloudflare returns `result` as a *bare array* of live-input items.
// This surfaces as an untyped `CloudflareHttpError` (status 200,
// statusText "Schema decode failed") thrown by the schema decoder before
// `list()`'s own code runs. It is a deterministic schema mismatch (not an
// entitlement issue), so the live list test is gated until distilled is
// patched. See the JSON report for the exact needed distilled patch.
// Run with CLOUDFLARE_TEST_STREAM_LIST=1 once distilled is fixed.
test.provider.skipIf(!process.env.CLOUDFLARE_TEST_STREAM_LIST)(
"list enumerates outputs across all live inputs",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
yield* stack.destroy();
const deployed = yield* stack.deploy(
Effect.gen(function* () {
const input = yield* Cloudflare.Stream.LiveInput("ListOutputInput", {
meta: { name: "alchemy-stream-output-list-input" },
});
const output = yield* Cloudflare.Stream.LiveInputOutput(
"ListOutput",
{
liveInputId: input.liveInputId,
url: "rtmps://a.rtmps.youtube.com/live2",
streamKey: "alchemy-list-stream-key",
},
);
return { input, output };
}),
);
const provider = yield* Provider.findProvider(
Cloudflare.Stream.LiveInputOutput,
);
// Edge propagation: the freshly-created output (and its parent live
// input enumeration) is eventually consistent — retry until present.
const all = yield* provider.list().pipe(
Effect.flatMap((rows) =>
rows.some((o) => o.outputId === deployed.output.outputId)
? Effect.succeed(rows)
: Effect.fail({ _tag: "OutputNotListed" } as const),
),
Effect.retry({
while: (e) => e._tag === "OutputNotListed",
schedule: Schedule.max([
Schedule.exponential("500 millis"),
Schedule.recurs(10),
]),
}),
);
const found = all.find((o) => o.outputId === deployed.output.outputId);
expect(found).toBeDefined();
expect(found?.liveInputId).toEqual(deployed.input.liveInputId);
expect(found?.accountId).toEqual(accountId);
expect(found?.url).toEqual("rtmps://a.rtmps.youtube.com/live2");
expect(found?.enabled).toBe(true);
yield* stack.destroy();
}).pipe(logLevel),
{
tags: ["provider:cloudflare", "provider:cloudflare:stream", "live"],
timeout: 120_000,
},
);