mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
d9968a31d4
commit
e120d6dfaa
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export type {
|
||||
FeatureDefinition,
|
||||
FeaturesManifest,
|
||||
FeaturePlatform,
|
||||
FeatureTier,
|
||||
} from "./types";
|
||||
export {
|
||||
useFeatureEnabled,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("@/")) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@features-manifest": ["../features.json"]
|
||||
"@features-manifest": ["../preview-features.json"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user