Files
tech-log-frontend/tests/unit/product-features.test.ts
T
DongHyeonkaandClaude Opus 5 93ce86eef4 test: decouple the feature-switch gates from the template's demo screens
The template's demonstration screens exist to explain the template. A product
replaces them with its domain, so deleting them is the expected end state — but
two gates were coupled to them, and the merge accommodated that coupling instead
of fixing it.

product-features.test.ts derives its own scope now: a registry must gate on the
manifest when it imports a module belonging to a manifest-declared feature. The
previous fix put installed-feature-runtimes.tsx in the exempt list, which
silenced the guard for that file permanently. Verified by removing the manifest
reference from installed-feature-adapters.ts and watching the guard fail; a
counter asserts the sweep still watches at least one file.

product-feature-switch.test.tsx exercises the kill switch end to end again. The
mechanism is the ownership lookup plus isFeatureActive, which has nothing to do
with which screens ship, so the ownership map is the fixture: one real
registered route attributed to a real installed feature, with the product's own
router, components and codecs.

That restoration exposed a real gap. The navigation-withdrawal half is not
implemented here: it lives in the template's PrimaryNavigation and this product
does not render the template's AppShell at all — the public header is a
hand-written list of paths. A disabled feature's route is refused by the router
but its link would still be advertised. Harmless only while no feature-owned
route is navigable, which is now asserted so the gap cannot ship silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 19:08:42 +09:00

245 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
activeProductFeatureIds,
resolveProductFeatures,
selectCompiledProductFeatures,
} from "../../src/contracts/product-features.ts";
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
import {
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
} from "../../src/features/installed-product-manifest.ts";
import { PLATFORM_ROUTE_REGISTRY } from "../../src/contracts/routes.ts";
import {
ROUTE_FEATURE_OWNER,
ROUTE_REGISTRY,
} from "../../src/features/installed-feature-contracts.ts";
const COMPILED = Object.freeze([
Object.freeze({ featureId: "reference-feature" }),
Object.freeze({ featureId: "billing" }),
]);
describe("build-time product selection", () => {
it("keeps everything when nothing is declared", () => {
for (const declared of [undefined, "", " "]) {
expect(
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
String(declared),
).toEqual(["reference-feature", "billing"]);
}
});
it("narrows to the declared subset", () => {
expect(
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
).toEqual(["billing"]);
expect(
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
(f) => f.featureId,
),
).toEqual(["reference-feature", "billing"]);
});
it("selects nothing only when asked explicitly", () => {
// A blank value keeps everything on purpose: an unset CI variable expands
// to a blank string, and that must not be how a build ships no features.
expect(selectCompiledProductFeatures(COMPILED, "none")).toEqual([]);
expect(selectCompiledProductFeatures(COMPILED, " none ")).toEqual([]);
expect(selectCompiledProductFeatures(COMPILED, "").length).toBe(2);
// A value that parses to no names at all is a typo, not an instruction.
expect(() => selectCompiledProductFeatures(COMPILED, ",")).toThrow(
/names no feature/u,
);
});
it("refuses to name a feature this build does not contain", () => {
// The whole point of the direction rule: an environment value can subtract
// from the source tree and must never be able to add to it. Accepting an
// unknown id silently would let a deployment believe it had switched on
// something that is not in the bundle.
expect(() => selectCompiledProductFeatures(COMPILED, "analytics")).toThrow(
/does not contain: analytics/u,
);
expect(() =>
selectCompiledProductFeatures(COMPILED, "billing,analytics"),
).toThrow(/analytics/u);
});
it("refuses a duplicated feature id in the manifest", () => {
expect(() =>
selectCompiledProductFeatures(
[{ featureId: "a" }, { featureId: "a" }],
undefined,
),
).toThrow(/duplicate product feature id/u);
});
});
describe("runtime product feature resolution", () => {
it("reports active, disabled and not-installed distinctly", () => {
const statuses = resolveProductFeatures(
["reference-feature", "billing"],
["reference-feature"],
{ "reference-feature": "DISABLED" },
);
expect(statuses).toEqual([
{ featureId: "billing", state: "NOT_INSTALLED" },
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
]);
expect(activeProductFeatureIds(statuses)).toEqual([]);
});
it("cannot switch on a feature the build left out", () => {
// `DEFAULT` on an uninstalled feature is not an instruction to install it.
const statuses = resolveProductFeatures(["billing"], [], {
billing: "DEFAULT",
});
expect(statuses).toEqual([{ featureId: "billing", state: "NOT_INSTALLED" }]);
expect(activeProductFeatureIds(statuses)).toEqual([]);
});
it("ignores an override naming a feature this build never declared", () => {
// A shared runtime document may cover several builds, so a stale key is
// inert rather than fatal.
const statuses = resolveProductFeatures(
["reference-feature"],
["reference-feature"],
{ analytics: "DISABLED" },
);
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
});
it("leaves an installed feature active without an override", () => {
const statuses = resolveProductFeatures(
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
);
expect(activeProductFeatureIds(statuses)).toEqual([
...INSTALLED_PRODUCT_FEATURE_IDS,
]);
});
});
describe("runtime config carries the switch", () => {
const base = {
APP_ENV: "local" as const,
API_BASE_URL: "http://localhost:8080/",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo" as const,
CONFIG_SCHEMA_VERSION: "2.0" as const,
RELEASE_MANIFEST_URL: "/release-manifest.json",
};
it("defaults to disabling nothing", () => {
const parsed = runtimeConfigV2ArtifactSchema.parse(base);
expect(parsed.FEATURE_OVERRIDES).toEqual({});
});
it("accepts only DEFAULT or DISABLED", () => {
expect(
runtimeConfigV2ArtifactSchema.parse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
}).FEATURE_OVERRIDES,
).toEqual({ "reference-feature": "DISABLED" });
// There is no "ENABLED": the vocabulary itself is what makes the rule
// unbreakable, not a check somewhere downstream.
expect(
runtimeConfigV2ArtifactSchema.safeParse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
}).success,
).toBe(false);
});
it("refuses a malformed feature id", () => {
for (const featureId of ["Reference", "reference_feature", "", "-x"]) {
expect(
runtimeConfigV2ArtifactSchema.safeParse({
...base,
FEATURE_OVERRIDES: { [featureId]: "DISABLED" },
}).success,
featureId,
).toBe(false);
}
});
});
describe("every installed registry consults the manifest", () => {
/**
* The manifest only means something if each registry actually asks it. A new
* registry that spreads a feature in directly would reintroduce exactly the
* coupling this file exists to remove, and nothing else would notice.
*/
it("gates every feature contribution on the selection", async () => {
const { readdir, readFile } = await import("node:fs/promises");
const nodePath = (await import("node:path")).default;
const root = "src/features";
const registries = (await readdir(root)).filter((entry) =>
/^installed-.*\.tsx?$/u.test(entry),
);
expect(registries.length).toBeGreaterThan(3);
const exempt = new Set([
// The manifest is the selection.
"installed-product-manifest.ts",
// Capabilities have their own §3.5 selection file and override vocabulary.
"installed-runtime-capabilities.ts",
// Message keys stay total on purpose; see the file for why.
"installed-feature-messages.ts",
]);
// A registry has to gate on the manifest when it composes something a
// manifest-declared feature owns. Deriving that from the file's own imports
// is the difference between a rule and a list: a product that replaces the
// template's demonstration feature with its own domain drops out of the
// rule honestly, and the day it imports a declared feature again the guard
// fires without anyone having to remember to re-add it.
//
// An earlier fix named `installed-feature-runtimes.tsx` in the exempt set
// once this product deleted the reference feature's screens. That silenced
// the guard for that file permanently — a worse outcome than the problem it
// solved.
let guarded = 0;
for (const registry of registries) {
if (exempt.has(registry)) continue;
const source = await readFile(nodePath.join(root, registry), "utf8");
const composesDeclaredFeature = COMPILED_PRODUCT_FEATURE_IDS.some(
(featureId) => source.includes(`./${featureId}/`),
);
if (!composesDeclaredFeature) continue;
guarded += 1;
expect(
/INSTALLED_PRODUCT_FEATURE(S|_IDS)/u.test(source),
`${registry} composes a manifest-declared feature and must gate on the manifest`,
).toBe(true);
}
// The rule has to still be watching something; an empty sweep would pass
// no matter what happened to these files.
expect(guarded).toBeGreaterThan(0);
});
});
describe("route ownership", () => {
it("attributes no platform route to a feature", () => {
// Template merge. The template named its own example routes here. This
// product deleted them, so the invariant is asserted against the platform
// registry itself: whatever the shell owns, disabling a feature can never
// withdraw it.
for (const routeId of Object.keys(PLATFORM_ROUTE_REGISTRY)) {
expect(ROUTE_FEATURE_OWNER[routeId], routeId).toBeUndefined();
expect(Object.keys(ROUTE_REGISTRY)).toContain(routeId);
}
expect(ROUTE_FEATURE_OWNER["NOT_FOUND"]).toBeUndefined();
});
it("owns exactly the routes the registry received from features", () => {
// Template merge. `owned` is empty in this product: the only installed
// feature's screens were removed by the UI migration. The invariant still
// holds and starts asserting again the moment a feature route returns.
for (const routeId of Object.keys(ROUTE_FEATURE_OWNER)) {
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
}
});
});