375 lines
12 KiB
JavaScript
375 lines
12 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
acknowledgeReleaseChannelSwitch,
|
|
buildReleaseFrontendRedirectUrl,
|
|
getReleaseChannelSwitchedStatus,
|
|
getReleaseChannelOptions,
|
|
getReleaseChannelUnavailableStatus,
|
|
hasSelectableReleaseChannels,
|
|
ignoreUnavailableReleaseChannel,
|
|
isReleaseChannelApiAvailabilityError,
|
|
markReleaseChannelApiUnavailable,
|
|
reconcileSelectedReleaseChannel,
|
|
RELEASE_CHANNEL_IGNORE_MS,
|
|
RELEASE_CHANNEL_IGNORE_STORAGE_KEY,
|
|
RELEASE_CHANNEL_SELECTION_STORAGE_KEY,
|
|
RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY,
|
|
releaseChannelRuntimeRequestParams,
|
|
selectReleaseChannel,
|
|
switchSelectedReleaseChannel,
|
|
__resetReleaseChannelAvailabilityForTests,
|
|
} from "@/services/releaseChannelAvailability.js";
|
|
import {
|
|
__resetReleaseTimelineForTests,
|
|
configureReleaseRuntime,
|
|
releaseRuntimeState,
|
|
} from "@/services/releaseTimeline.js";
|
|
|
|
const canaryRuntime = {
|
|
channel: {
|
|
id: 2,
|
|
slug: "canary",
|
|
name: "Canary",
|
|
description: "Early validation channel",
|
|
default_channel: false,
|
|
},
|
|
versions: {
|
|
frontend: null,
|
|
api: null,
|
|
bundle_id: null,
|
|
},
|
|
availability: {
|
|
configured: false,
|
|
missing: ["release_bundle", "frontend_version", "api_version"],
|
|
status: "unconfigured",
|
|
},
|
|
};
|
|
|
|
const configuredCanaryRuntime = {
|
|
...canaryRuntime,
|
|
versions: {
|
|
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
|
|
api: { version_label: "api-canary", commit_sha: "feedface" },
|
|
},
|
|
availability: {
|
|
configured: true,
|
|
missing: [],
|
|
status: "ready",
|
|
},
|
|
};
|
|
|
|
describe("release channel availability", () => {
|
|
afterEach(() => {
|
|
__resetReleaseChannelAvailabilityForTests();
|
|
__resetReleaseTimelineForTests();
|
|
});
|
|
|
|
it("blocks a non-default assigned channel when API targets are missing", () => {
|
|
const status = getReleaseChannelUnavailableStatus(canaryRuntime, 1_000, 0);
|
|
|
|
expect(status.shouldBlock).toBe(true);
|
|
expect(status.channelSlug).toBe("canary");
|
|
expect(status.missing).toEqual(["api_version"]);
|
|
});
|
|
|
|
it("does not block non-default channels only because frontend release assets are missing", () => {
|
|
const status = getReleaseChannelUnavailableStatus(
|
|
{
|
|
...canaryRuntime,
|
|
versions: {
|
|
frontend: null,
|
|
api: { version_label: "api-canary", commit_sha: "feedface" },
|
|
},
|
|
availability: {
|
|
configured: false,
|
|
missing: ["release_bundle", "frontend_version", "frontend_base_url", "frontend_entry"],
|
|
status: "unconfigured",
|
|
},
|
|
},
|
|
1_000,
|
|
0
|
|
);
|
|
|
|
expect(status.configured).toBe(true);
|
|
expect(status.shouldBlock).toBe(false);
|
|
expect(status.missing).toEqual([]);
|
|
});
|
|
|
|
it("does not block the stable default channel even when runtime targets are absent", () => {
|
|
const status = getReleaseChannelUnavailableStatus({
|
|
channel: {
|
|
slug: "stable",
|
|
name: "Stable",
|
|
default_channel: true,
|
|
},
|
|
availability: {
|
|
configured: false,
|
|
missing: ["release_bundle"],
|
|
},
|
|
});
|
|
|
|
expect(status.unavailable).toBe(false);
|
|
expect(status.shouldBlock).toBe(false);
|
|
});
|
|
|
|
it("does not block branch-based channels only because a legacy bundle is missing", () => {
|
|
const status = getReleaseChannelUnavailableStatus({
|
|
channel: {
|
|
slug: "canary",
|
|
name: "Canary",
|
|
default_channel: false,
|
|
},
|
|
availability: {
|
|
configured: false,
|
|
missing: ["release_bundle"],
|
|
status: "unconfigured",
|
|
},
|
|
});
|
|
|
|
expect(status.configured).toBe(true);
|
|
expect(status.shouldBlock).toBe(false);
|
|
expect(status.missing).toEqual([]);
|
|
expect(status.status).toBe("ready");
|
|
});
|
|
|
|
it("exposes selectable runtime channels when a non-default assignment is available", () => {
|
|
const runtime = {
|
|
channel: configuredCanaryRuntime.channel,
|
|
versions: configuredCanaryRuntime.versions,
|
|
availability: configuredCanaryRuntime.availability,
|
|
availableChannels: [
|
|
{
|
|
channel: {
|
|
slug: "stable",
|
|
name: "Stable",
|
|
default_channel: true,
|
|
},
|
|
availability: {
|
|
configured: true,
|
|
missing: [],
|
|
status: "ready",
|
|
},
|
|
},
|
|
{
|
|
channel: configuredCanaryRuntime.channel,
|
|
versions: configuredCanaryRuntime.versions,
|
|
availability: configuredCanaryRuntime.availability,
|
|
},
|
|
],
|
|
};
|
|
|
|
const options = getReleaseChannelOptions(runtime);
|
|
|
|
expect(options.map((option) => option.channelSlug)).toEqual(["stable", "canary"]);
|
|
expect(hasSelectableReleaseChannels(runtime)).toBe(true);
|
|
});
|
|
|
|
it("hides the selector when only the default stable channel is available", () => {
|
|
expect(
|
|
hasSelectableReleaseChannels({
|
|
channel: {
|
|
slug: "stable",
|
|
name: "Stable",
|
|
default_channel: true,
|
|
},
|
|
availableChannels: [
|
|
{
|
|
channel: {
|
|
slug: "stable",
|
|
name: "Stable",
|
|
default_channel: true,
|
|
},
|
|
},
|
|
],
|
|
})
|
|
).toBe(false);
|
|
});
|
|
|
|
it("stores the selected release channel for runtime refresh requests", () => {
|
|
const selectedSlug = selectReleaseChannel({ slug: "canary" });
|
|
|
|
expect(selectedSlug).toBe("canary");
|
|
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBe("canary");
|
|
expect(releaseChannelRuntimeRequestParams()).toEqual({ release_channel: "canary" });
|
|
});
|
|
|
|
it("rolls back a selected channel when the runtime refresh fails", async () => {
|
|
let refreshCalls = 0;
|
|
|
|
await expect(
|
|
switchSelectedReleaseChannel({ slug: "canary" }, async () => {
|
|
refreshCalls += 1;
|
|
if (refreshCalls > 1) {
|
|
return { channel: { slug: "stable", name: "Stable", default_channel: true } };
|
|
}
|
|
throw new Error("runtime unavailable");
|
|
})
|
|
).rejects.toThrow("runtime unavailable");
|
|
|
|
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
|
|
expect(releaseChannelRuntimeRequestParams()).toEqual({});
|
|
expect(refreshCalls).toBe(2);
|
|
});
|
|
|
|
it("rejects and rolls back when the refreshed runtime confirms a different channel", async () => {
|
|
configureReleaseRuntime({
|
|
channel: { slug: "stable", name: "Stable", default_channel: true },
|
|
availableChannels: [
|
|
{ channel: { slug: "stable", name: "Stable", default_channel: true } },
|
|
{ channel: configuredCanaryRuntime.channel },
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
switchSelectedReleaseChannel({ slug: "canary" }, async () => ({
|
|
channel: { slug: "stable", name: "Stable", default_channel: true },
|
|
}))
|
|
).rejects.toThrow("instead of canary");
|
|
|
|
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
|
|
});
|
|
|
|
it("keeps a selected channel only after the refreshed runtime confirms it", async () => {
|
|
const selectedSlug = await switchSelectedReleaseChannel({ slug: "canary" }, async () => configuredCanaryRuntime);
|
|
|
|
expect(selectedSlug).toBe("canary");
|
|
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBe("canary");
|
|
});
|
|
|
|
it("clears a stored selection when the runtime no longer offers that channel", () => {
|
|
selectReleaseChannel("canary");
|
|
|
|
const selectedSlug = reconcileSelectedReleaseChannel({
|
|
availableChannels: [
|
|
{
|
|
channel: {
|
|
slug: "stable",
|
|
name: "Stable",
|
|
default_channel: true,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(selectedSlug).toBe("");
|
|
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
|
|
});
|
|
|
|
it("does not block legacy runtime payloads that omit availability and target URLs", () => {
|
|
const runtime = {
|
|
channel: {
|
|
slug: "canary",
|
|
name: "Canary",
|
|
default_channel: false,
|
|
},
|
|
};
|
|
const status = getReleaseChannelUnavailableStatus(runtime);
|
|
const switchedStatus = getReleaseChannelSwitchedStatus(runtime, "user:42", {});
|
|
|
|
expect(status.configured).toBe(true);
|
|
expect(status.shouldBlock).toBe(false);
|
|
expect(switchedStatus.shouldShow).toBe(false);
|
|
});
|
|
|
|
it("supports a five-minute channel-specific ignore window", () => {
|
|
const ignoredUntil = ignoreUnavailableReleaseChannel(canaryRuntime.channel);
|
|
const stored = JSON.parse(window.localStorage.getItem(RELEASE_CHANNEL_IGNORE_STORAGE_KEY));
|
|
const status = getReleaseChannelUnavailableStatus(canaryRuntime, Date.now(), ignoredUntil);
|
|
|
|
expect(ignoredUntil).toBeGreaterThan(Date.now() + RELEASE_CHANNEL_IGNORE_MS - 2_000);
|
|
expect(stored.canary).toBe(ignoredUntil);
|
|
expect(status.ignored).toBe(true);
|
|
expect(status.shouldBlock).toBe(false);
|
|
});
|
|
|
|
it("shows a one-time switched-channel notice for configured non-default channels", () => {
|
|
const status = getReleaseChannelSwitchedStatus(configuredCanaryRuntime, "user:42", {});
|
|
|
|
expect(status.shouldShow).toBe(true);
|
|
expect(status.noticeKey).toBe("user:42:canary");
|
|
});
|
|
|
|
it("stores switched-channel acknowledgement per account and channel", () => {
|
|
const noticeKey = acknowledgeReleaseChannelSwitch(configuredCanaryRuntime.channel, "user:42");
|
|
const stored = JSON.parse(window.localStorage.getItem(RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY));
|
|
const status = getReleaseChannelSwitchedStatus(configuredCanaryRuntime, "user:42", stored);
|
|
const otherUserStatus = getReleaseChannelSwitchedStatus(configuredCanaryRuntime, "user:99", stored);
|
|
|
|
expect(noticeKey).toBe("user:42:canary");
|
|
expect(stored["user:42:canary"]).toEqual(expect.any(String));
|
|
expect(status.shouldShow).toBe(false);
|
|
expect(otherUserStatus.shouldShow).toBe(true);
|
|
});
|
|
|
|
it("does not show the switched-channel notice for default channels or unknown accounts", () => {
|
|
const defaultStatus = getReleaseChannelSwitchedStatus(
|
|
{
|
|
...configuredCanaryRuntime,
|
|
channel: {
|
|
...configuredCanaryRuntime.channel,
|
|
default_channel: true,
|
|
},
|
|
},
|
|
"user:42",
|
|
{}
|
|
);
|
|
const anonymousStatus = getReleaseChannelSwitchedStatus(configuredCanaryRuntime, "", {});
|
|
|
|
expect(defaultStatus.shouldShow).toBe(false);
|
|
expect(anonymousStatus.shouldShow).toBe(false);
|
|
});
|
|
|
|
it("does not build a channel-specific frontend redirect", () => {
|
|
const redirectUrl = buildReleaseFrontendRedirectUrl(
|
|
{
|
|
channel: { slug: "beta", default_channel: false },
|
|
versions: {
|
|
frontend: { version_label: "frontend-beta" },
|
|
api: { version_label: "api-beta" },
|
|
bundle_id: 52,
|
|
},
|
|
},
|
|
{
|
|
origin: "https://app.example.test",
|
|
pathname: "/user/bookings",
|
|
search: "?page=2",
|
|
hash: "#next",
|
|
}
|
|
);
|
|
|
|
expect(redirectUrl).toBeNull();
|
|
});
|
|
|
|
it("classifies selected channel auth session 404s as release API availability failures", () => {
|
|
configureReleaseRuntime({
|
|
trace_id: "trace-channel-api",
|
|
channel: { slug: "internal", name: "Intern", default_channel: false },
|
|
versions: {
|
|
frontend: { version_label: "frontend-internal" },
|
|
api: { version_label: "api-internal" },
|
|
bundle_id: 42,
|
|
},
|
|
api_base_url: "https://api-v2.truckwash.io/internal/api",
|
|
frontend_base_url: "https://api-v2.truckwash.io/internal/frontend",
|
|
});
|
|
|
|
const error = {
|
|
response: { status: 404 },
|
|
config: { url: "https://api-v2.truckwash.io/internal/api/auth/session" },
|
|
};
|
|
|
|
expect(isReleaseChannelApiAvailabilityError(error)).toBe(true);
|
|
|
|
const unavailableRuntime = markReleaseChannelApiUnavailable();
|
|
|
|
expect(unavailableRuntime.availability).toMatchObject({
|
|
configured: false,
|
|
status: "unconfigured",
|
|
explicit: true,
|
|
});
|
|
expect(unavailableRuntime.availability.missing).toContain("api_base_url");
|
|
expect(releaseRuntimeState.availability.configured).toBe(false);
|
|
});
|
|
});
|