feat: make product features a declared selection with a runtime kill switch
Which features a build contains was not a decision anybody could express. The reference feature was spread directly into the route, API, schema, codec and adapter registries, so shipping without it meant editing five files by hand and hoping nothing still referred to it — and there was no way at all to take it out of service on a running deployment. The removability gate proved the editing worked; nothing made it a choice. There is now one manifest. `VITE_PRODUCT_FEATURES` narrows it at build time and `FEATURE_OVERRIDES` in the runtime document takes an installed feature out of service without a rebuild. Every registry composes from the manifest, and a test fails if a new one forgets to. Both inputs are subtractive, and the vocabulary is what enforces it rather than a check somewhere downstream: the override enum has no `ENABLED`, and a build-time selection naming something the source tree does not declare is refused instead of ignored. A configuration document that could name a feature into existence would be a configuration document choosing which code runs. Disabling is not just hiding. Withdrawing a route from navigation would leave a typed deep link that still mounts the feature, so the router refuses it too and answers with a surface that says the deployment switched it off. The platform overview now distinguishes the three states an operator actually needs: serving, switched off, and not in this build. What this is not: an env var does not shrink the bundle. A static import cannot be undone by a value, and making the import graph itself depend on a configuration string is the thing §3.5 exists to prevent — measured, `none` changes the output by 58 bytes. Physical removal remains FE-GATE-020's job, and the code comments say so rather than implying otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0a97d235e4
commit
711d61e73f
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "../../src/features/installed-product-manifest.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { AppRouter } from "../../src/presentation/routes/app-router.tsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createProductFeaturesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
/**
|
||||
* §3.5. The runtime kill switch, exercised through the running app rather than
|
||||
* through the resolver that computes it.
|
||||
*
|
||||
* Withdrawing a feature from navigation is not the same as taking it out of
|
||||
* service: a typed deep link would still mount it. Both halves are asserted
|
||||
* here, on the same render, so the switch cannot be half-wired.
|
||||
*/
|
||||
|
||||
const FEATURE_ID = INSTALLED_PRODUCT_FEATURE_IDS[0]!;
|
||||
const FEATURE_ROUTE = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
|
||||
|
||||
function renderAt(path: string, disabled: boolean) {
|
||||
window.history.pushState({}, "", path);
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
...(disabled
|
||||
? {
|
||||
productFeatures: createProductFeaturesStub({
|
||||
[FEATURE_ID]: "DISABLED_BY_CONFIG",
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("runtime product feature switch", () => {
|
||||
it("advertises the feature's route while the feature is active", async () => {
|
||||
renderAt("/", false);
|
||||
expect(
|
||||
await screen.findByRole("link", { name: FEATURE_ROUTE.navigationLabel! }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("withdraws the feature's route from navigation when it is disabled", async () => {
|
||||
renderAt("/", true);
|
||||
// The shell itself still renders: disabling a feature is not an outage.
|
||||
expect(await screen.findByRole("navigation")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: FEATURE_ROUTE.navigationLabel! }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the feature out of service for a direct deep link", async () => {
|
||||
renderAt(FEATURE_ROUTE.path, true);
|
||||
const surface = await screen.findByText(
|
||||
(_, element) =>
|
||||
element?.getAttribute("data-disabled-feature") === FEATURE_ID,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(surface).toBeTruthy();
|
||||
});
|
||||
|
||||
it("serves the same deep link while the feature is active", async () => {
|
||||
renderAt(FEATURE_ROUTE.path, false);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
(_, element) =>
|
||||
element?.getAttribute("data-disabled-feature") === FEATURE_ID,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const runtime: Runtime = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
configSchema: "V2",
|
||||
build: {
|
||||
@@ -83,6 +84,23 @@ function referenceBoundQueryKey() {
|
||||
).queryKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed feature inputs are partial by design: a feature the product
|
||||
* manifest did not select supplies none. This suite is about the reference
|
||||
* feature being composed, so it asserts that first and narrows once.
|
||||
*/
|
||||
function referenceInput<
|
||||
Inputs extends Readonly<Partial<Record<typeof REFERENCE_FEATURE_ID, unknown>>>,
|
||||
>(adapters: Readonly<{ featureInputs: Inputs }>) {
|
||||
const input = adapters.featureInputs[REFERENCE_FEATURE_ID];
|
||||
if (!input) {
|
||||
throw new Error(
|
||||
`${REFERENCE_FEATURE_ID} is not installed; the manifest did not select it`,
|
||||
);
|
||||
}
|
||||
return input as NonNullable<Inputs[typeof REFERENCE_FEATURE_ID]>;
|
||||
}
|
||||
|
||||
describe("reference feature runtime composition", () => {
|
||||
it("invalidates a real bound query through the installed production graph", async () => {
|
||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||
@@ -117,7 +135,7 @@ describe("reference feature runtime composition", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapters.featureInputs[REFERENCE_FEATURE_ID].listResources({ limit: 20 }),
|
||||
referenceInput(adapters).listResources({ limit: 20 }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: [
|
||||
@@ -170,7 +188,7 @@ describe("reference feature runtime composition", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapters.featureInputs[REFERENCE_FEATURE_ID].createResource(
|
||||
referenceInput(adapters).createResource(
|
||||
{ name: "Created resource" },
|
||||
{ intent },
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import type { ApplicationFeatureInputs } from "../../src/application/ports/in/application-api.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts";
|
||||
|
||||
type TestApplicationOverrides = Partial<ApplicationOutputPorts> &
|
||||
Readonly<{
|
||||
@@ -52,6 +52,8 @@ export function createTestApplication(
|
||||
},
|
||||
runtimeCapabilities:
|
||||
overrides.runtimeCapabilities ?? createRuntimeCapabilitiesStub(),
|
||||
productFeatures:
|
||||
overrides.productFeatures ?? createProductFeaturesStub(),
|
||||
navigation: overrides.navigation ?? { reload: () => {} },
|
||||
},
|
||||
overrides.featureInputs,
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
activeProductFeatureIds,
|
||||
resolveProductFeatures,
|
||||
type ProductFeatureState,
|
||||
} from "../../src/contracts/product-features.ts";
|
||||
import type { ProductFeaturesPort } from "../../src/application/ports/product-features-port.ts";
|
||||
import {
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
} from "../../src/features/installed-product-manifest.ts";
|
||||
import type {
|
||||
RuntimeCapabilityId,
|
||||
RuntimeCapabilityStatus,
|
||||
@@ -32,3 +42,27 @@ export function createRuntimeCapabilitiesStub(
|
||||
);
|
||||
return Object.freeze({ getSnapshot: () => snapshot });
|
||||
}
|
||||
|
||||
/**
|
||||
* A product-feature port that reports the real manifest with nothing disabled.
|
||||
* Tests that care about the switch build their own; the rest only need the
|
||||
* boundary to be complete.
|
||||
*/
|
||||
export function createProductFeaturesStub(
|
||||
overrides: Readonly<Record<string, ProductFeatureState>> = {},
|
||||
): ProductFeaturesPort {
|
||||
const snapshot = resolveProductFeatures(
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
Object.fromEntries(
|
||||
Object.entries(overrides)
|
||||
.filter(([, state]) => state === "DISABLED_BY_CONFIG")
|
||||
.map(([featureId]) => [featureId, "DISABLED" as const]),
|
||||
),
|
||||
);
|
||||
const active = new Set(activeProductFeatureIds(snapshot));
|
||||
return Object.freeze({
|
||||
getSnapshot: () => snapshot,
|
||||
isActive: (featureId: string) => active.has(featureId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const runtime: Parameters<typeof loadReleaseManifest>[0] = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
configSchema: "V2",
|
||||
validationDurationMs: 0,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
declare module "../../src/application/ports/in/application-api.ts" {
|
||||
interface ApplicationFeatureInputs {
|
||||
@@ -99,6 +99,7 @@ describe("application input/output boundary", () => {
|
||||
}),
|
||||
},
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
productFeatures: createProductFeaturesStub(),
|
||||
navigation: { reload: () => {} },
|
||||
} satisfies ApplicationOutputPorts;
|
||||
const application = createApplication(ports);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
|
||||
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts";
|
||||
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
type ReleaseFixture = {
|
||||
buildId: string;
|
||||
@@ -53,6 +53,7 @@ function applicationWith(options: {
|
||||
diagnostics: options.diagnostics ?? { record: () => {} },
|
||||
telemetry: options.telemetry ?? { emit: () => {} },
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
productFeatures: createProductFeaturesStub(),
|
||||
releaseInfo: {
|
||||
getCurrent: async () => current,
|
||||
refresh:
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
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 {
|
||||
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",
|
||||
]);
|
||||
for (const registry of registries) {
|
||||
if (exempt.has(registry)) continue;
|
||||
const source = await readFile(nodePath.join(root, registry), "utf8");
|
||||
expect(
|
||||
/INSTALLED_PRODUCT_FEATURE(S|_IDS)/u.test(source),
|
||||
`${registry} must compose from the product manifest`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("route ownership", () => {
|
||||
it("attributes every feature route to its feature and no platform route", () => {
|
||||
for (const featureId of INSTALLED_PRODUCT_FEATURE_IDS) {
|
||||
expect(Object.values(ROUTE_FEATURE_OWNER)).toContain(featureId);
|
||||
}
|
||||
// Platform routes have no owner, so disabling a feature can never withdraw
|
||||
// the shell's own navigation.
|
||||
for (const routeId of ["APP_HOME", "NOT_FOUND", "EXAMPLES_PLATFORM"]) {
|
||||
expect(ROUTE_FEATURE_OWNER[routeId], routeId).toBeUndefined();
|
||||
expect(Object.keys(ROUTE_REGISTRY)).toContain(routeId);
|
||||
}
|
||||
});
|
||||
|
||||
it("owns exactly the routes the registry received from features", () => {
|
||||
const owned = Object.keys(ROUTE_FEATURE_OWNER);
|
||||
expect(owned.length).toBeGreaterThan(0);
|
||||
for (const routeId of owned) {
|
||||
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ const runtimeV2 = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
} as const satisfies RuntimeConfigArtifact;
|
||||
|
||||
async function releaseV2With(
|
||||
|
||||
@@ -26,6 +26,7 @@ const runtime: Runtime = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
configSchema: "V2",
|
||||
build: {
|
||||
@@ -262,6 +263,7 @@ describe("runtime adapter composition", () => {
|
||||
...runtime.config.CAPABILITY_OVERRIDES,
|
||||
SERVICE_WORKER: "DISABLED",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
},
|
||||
release,
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 53 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 424 KiB |
Reference in New Issue
Block a user