refactor(features): preview-only manifest + screenshot CI wiring

Per tho's clarification, the manifest is preview-only by design — a bad
rebase had widened it to include stable entries with an explicit `tier`
field. Restoring the original shape:

- Rename `features.json` -> `preview-features.json` (loader, alias,
  ts-config, vite alias, test-loader-hooks, helpers, log strings).
- Drop the 4 stable entries from the manifest. Only the 4 preview
  features remain (workflows, projects, pulse, forum).
- Drop the `tier` field from the schema, types, and Zod validator —
  manifest membership is now sufficient ("in the file = preview;
  absent = stable, fail-open").
- Simplify `resolveEnabled(featureId, overrides)` — once you're inside
  it, the feature is preview by definition.
- `useFeatureEnabled`: in-manifest -> check overrides; otherwise return
  true (fail-open).
- `usePreviewFeatureWarning`: gate on manifest membership instead of
  `tier === 'preview'`.
- Settings: `ExperimentalFeaturesCard` lists every desktop feature in
  the manifest directly; SettingsView feature gate uses the new
  `resolveEnabled` signature.
- Tests: rewrote `resolveEnabled.test.mjs` for the new signature; helper
  drops the tier filter.

Per Marge's review on the previous push, `screenshot-feature-flags.ts`
was dropped from smoke testMatch but no CI step invoked the dedicated
screenshot config — coverage was dark. Adding a `Desktop screenshot e2e`
step in `.github/workflows/ci.yml` that runs `--config=
playwright-screenshot.config.ts` after the smoke step, restoring
coverage without dirtying smoke.

Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-08 16:54:57 -07:00
parent d9968a31d4
commit e120d6dfaa
16 changed files with 101 additions and 147 deletions
+2
View File
@@ -170,6 +170,8 @@ jobs:
run: just desktop-build
- name: Desktop smoke e2e
run: cd desktop && pnpm exec playwright test --project=smoke
- name: Desktop screenshot e2e
run: cd desktop && pnpm exec playwright test --config=playwright-screenshot.config.ts
- name: Desktop Tauri check
run: just desktop-tauri-check
env:
@@ -25,7 +25,9 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) {
}
export function ExperimentalFeaturesCard() {
const previewFeatures = desktopFeatures.filter((f) => f.tier === "preview");
// Manifest is preview-only by definition; every desktop entry is a preview
// feature.
const previewFeatures = desktopFeatures;
return (
<section className="min-w-0" data-testid="settings-experimental">
@@ -131,13 +131,12 @@ export function SettingsView({
const membership = myMembershipQuery.data;
return settingsSections.filter((s) => {
// Feature gate check
// Feature gate check. Manifest is preview-only — if the gate id is in
// the manifest, it's preview and needs an opt-in; if it's not, it's
// stable and renders unconditionally (fail-open).
if (s.featureGate) {
const feature = getFeature(s.featureGate);
if (
feature &&
!resolveEnabled(feature.tier, feature.id, featureState)
) {
if (feature && !resolveEnabled(s.featureGate, featureState)) {
return false;
}
}
-1
View File
@@ -5,7 +5,6 @@ export type {
FeatureDefinition,
FeaturesManifest,
FeaturePlatform,
FeatureTier,
} from "./types";
export {
useFeatureEnabled,
+2 -4
View File
@@ -3,20 +3,18 @@ import { z } from "zod";
import type { FeatureDefinition, FeaturesManifest } from "./types";
// ---------------------------------------------------------------------------
// Schema — runtime-validates the bundled features.json at startup.
// Schema — runtime-validates the bundled preview-features.json at startup.
//
// On parse failure we fall back to an empty manifest and log a console warning.
// The app keeps working; gated UI stays hidden; nothing accidentally leaks.
// ---------------------------------------------------------------------------
const FeatureTierSchema = z.enum(["stable", "preview"]);
const FeaturePlatformSchema = z.enum(["desktop", "mobile"]);
const FeatureDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string(),
tier: FeatureTierSchema,
platforms: z.array(FeaturePlatformSchema).optional(),
});
@@ -31,7 +29,7 @@ function loadManifest(): FeaturesManifest {
const result = FeaturesManifestSchema.safeParse(manifestJson);
if (!result.success) {
console.warn(
"[FeatureFlags] features.json failed schema validation; falling back to empty manifest.",
"[FeatureFlags] preview-features.json failed schema validation; falling back to empty manifest.",
result.error.issues,
);
return EMPTY_MANIFEST;
@@ -3,38 +3,20 @@ import { describe, it } from "node:test";
import { resolveEnabled } from "./resolveEnabled.ts";
describe("resolveEnabled", () => {
describe("stable tier", () => {
it("always returns true regardless of overrides", () => {
assert.equal(resolveEnabled("stable", "doctor", {}), true);
assert.equal(resolveEnabled("stable", "doctor", { doctor: false }), true);
});
describe("resolveEnabled (preview-only)", () => {
it("returns false by default (no override)", () => {
assert.equal(resolveEnabled("workflows", {}), false);
});
describe("preview tier", () => {
it("returns false by default (no override)", () => {
assert.equal(resolveEnabled("preview", "workflows", {}), false);
});
it("returns true when user opts in", () => {
assert.equal(
resolveEnabled("preview", "workflows", { workflows: true }),
true,
);
});
it("returns false when user explicitly opts out", () => {
assert.equal(
resolveEnabled("preview", "workflows", { workflows: false }),
false,
);
});
it("returns true when user opts in", () => {
assert.equal(resolveEnabled("workflows", { workflows: true }), true);
});
describe("unknown tier", () => {
it("returns false for unrecognized tier values", () => {
// @ts-expect-error — testing invalid input
assert.equal(resolveEnabled("unknown", "foo", {}), false);
});
it("returns false when user explicitly opts out", () => {
assert.equal(resolveEnabled("workflows", { workflows: false }), false);
});
it("ignores overrides for unrelated ids", () => {
assert.equal(resolveEnabled("workflows", { pulse: true }), false);
});
});
+10 -13
View File
@@ -1,20 +1,17 @@
import type { FeatureTier } from "./types";
/**
* Pure resolution logic for feature visibility.
* No side effects, no imports beyond types safe to test in isolation.
* Pure resolution logic for preview-feature visibility.
* No side effects, no imports safe to test in isolation.
*
* The manifest (`preview-features.json`) lists only preview features.
* Anything not in the manifest is stable and resolves true elsewhere
* (see `useFeatureEnabled`). Once you're inside `resolveEnabled`, the
* feature IS in the manifest preview by definition.
*
* Returns true only if the user has explicitly opted in via overrides.
*/
export function resolveEnabled(
tier: FeatureTier,
featureId: string,
overrides: Record<string, boolean>,
): boolean {
switch (tier) {
case "stable":
return true;
case "preview":
return overrides[featureId] === true;
default:
return false;
}
return overrides[featureId] === true;
}
+7 -5
View File
@@ -1,15 +1,17 @@
/** Feature visibility tiers */
export type FeatureTier = "stable" | "preview";
/** Platforms a feature is available on */
export type FeaturePlatform = "desktop" | "mobile";
/** A single feature definition from the manifest */
/**
* A single feature definition from the manifest.
*
* The manifest (`preview-features.json`) lists ONLY preview features
* membership signals "this needs gating." Anything not in the manifest is
* treated as stable and renders unconditionally (fail-open).
*/
export interface FeatureDefinition {
id: string;
name: string;
description: string;
tier: FeatureTier;
/** If omitted, feature is available on all platforms */
platforms?: FeaturePlatform[];
}
@@ -91,29 +91,24 @@ export function useFeatureSnapshot(): Record<string, boolean> {
}
/**
* Returns whether a feature is enabled given its tier and user overrides.
* Returns whether a feature is enabled.
*
* - stable: always true
* - preview: true only if user opted in
* - unknown id: fail-open (returns true). Manifest membership signals "this
* needs gating"; absence means "just render it." A stray `<FeatureGate>`
* pointing at a removed id should not hide UI. Dev mode still logs a
* `console.warn` so typos surface during development.
* The manifest (`preview-features.json`) lists ONLY preview features:
*
* - in manifest (preview): true only if the user opted in via overrides
* - NOT in manifest (stable): always true (fail-open)
*
* Membership in the manifest signals "this needs gating"; absence means
* "just render it." A stray `<FeatureGate feature="removed-id">` will never
* hide UI.
*/
export function useFeatureEnabled(featureId: string): boolean {
const overrides = useFeatureSnapshot();
const feature = getFeature(featureId);
if (!feature) {
if (import.meta.env.DEV) {
console.warn(
`[FeatureFlags] Unknown feature id: "${featureId}". Check features.json.`,
);
}
return true;
}
if (!feature) return true;
return resolveEnabled(feature.tier, featureId, overrides);
return resolveEnabled(featureId, overrides);
}
/**
@@ -153,7 +148,9 @@ export function usePreviewFeatureWarning(featureId: string): void {
const feature = getFeature(featureId);
useEffect(() => {
if (feature?.tier !== "preview" || enabled) return;
// No-op for stable features (not in manifest) and preview features
// that ARE enabled. Manifest membership = preview by definition.
if (!feature || enabled) return;
let cancelled = false;
void import("sonner").then(({ toast }) => {
if (cancelled) return;
+1 -1
View File
@@ -13,7 +13,7 @@ const repoRoot = path.resolve(
export function resolve(specifier, context, nextResolve) {
if (specifier === "@features-manifest") {
const resolved = path.join(repoRoot, "features.json");
const resolved = path.join(repoRoot, "preview-features.json");
return nextResolve(resolved, context);
}
if (specifier.startsWith("@/")) {
+1 -1
View File
@@ -99,7 +99,7 @@ type BridgeOptions = {
relayWsUrl?: string;
skipOnboardingSeed?: boolean;
/**
* When true (default), seed every preview feature in features.json as
* When true (default), seed every preview feature in preview-features.json as
* enabled in localStorage so E2E tests can interact with gated UI without
* clicking through the Experiments settings panel. Set to false in specs
* that test the toggle behavior itself (e.g.
+7 -7
View File
@@ -1,15 +1,16 @@
// Single source of truth for E2E tests: derive the preview-feature list from
// /features.json so we don't have to hand-maintain a parallel array.
// /preview-features.json so we don't have to hand-maintain a parallel array.
//
// Tier transitions (preview → stable, or new preview features added) are
// picked up automatically by every test that imports from here.
import featuresManifest from "../../../features.json" with { type: "json" };
// New preview features added to the manifest are picked up automatically by
// every test that imports from here.
import featuresManifest from "../../../preview-features.json" with {
type: "json",
};
interface FeatureDefinition {
id: string;
name: string;
description: string;
tier: "stable" | "preview";
platforms?: string[];
}
@@ -20,8 +21,7 @@ interface FeaturesManifest {
const manifest = featuresManifest as FeaturesManifest;
/** IDs of every preview-tier feature on desktop. */
/** IDs of every preview feature on desktop. */
export const PREVIEW_FEATURE_IDS: string[] = manifest.features
.filter((f) => f.tier === "preview")
.filter((f) => !f.platforms || f.platforms.includes("desktop"))
.map((f) => f.id);
+1 -1
View File
@@ -7,7 +7,7 @@
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"],
"@features-manifest": ["../features.json"]
"@features-manifest": ["../preview-features.json"]
},
/* Bundler mode */
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig(async () => ({
resolve: {
alias: {
"@": "/src",
"@features-manifest": path.resolve(__dirname, "../features.json"),
"@features-manifest": path.resolve(__dirname, "../preview-features.json"),
},
},
-61
View File
@@ -1,61 +0,0 @@
{
"version": 1,
"features": [
{
"id": "managed-agents",
"name": "Managed Agents",
"description": "Create, configure, and run AI agents in your workspace",
"tier": "stable",
"platforms": ["desktop"]
},
{
"id": "channel-templates",
"name": "Channel Templates",
"description": "Pre-configured channel setups with agents and workflows",
"tier": "stable",
"platforms": ["desktop"]
},
{
"id": "custom-emoji",
"name": "Custom Emoji",
"description": "Workspace emoji palette for reactions and messages",
"tier": "stable",
"platforms": ["desktop"]
},
{
"id": "doctor",
"name": "Doctor",
"description": "Diagnostic and debug panel for troubleshooting",
"tier": "stable",
"platforms": ["desktop"]
},
{
"id": "workflows",
"name": "Workflows",
"description": "YAML-defined automations with approval gates",
"tier": "preview",
"platforms": ["desktop"]
},
{
"id": "projects",
"name": "Projects",
"description": "Git repository browser and collaboration",
"tier": "preview",
"platforms": ["desktop"]
},
{
"id": "pulse",
"name": "Pulse",
"description": "Activity feed with notes, social posts, and agent activity",
"tier": "preview",
"platforms": ["desktop"]
},
{
"id": "forum",
"name": "Forum Channels",
"description": "Forum-style threaded channels for long-form discussions",
"tier": "preview",
"platforms": ["desktop"]
}
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"version": 1,
"features": [
{
"id": "workflows",
"name": "Workflows",
"description": "YAML-defined automations with approval gates",
"platforms": [
"desktop"
]
},
{
"id": "projects",
"name": "Projects",
"description": "Git repository browser and collaboration",
"platforms": [
"desktop"
]
},
{
"id": "pulse",
"name": "Pulse",
"description": "Activity feed with notes, social posts, and agent activity",
"platforms": [
"desktop"
]
},
{
"id": "forum",
"name": "Forum Channels",
"description": "Forum-style threaded channels for long-form discussions",
"platforms": [
"desktop"
]
}
]
}