Files
coder/site/src/hooks/useEmbeddedMetadata.test.ts
T
Dean Sheather e57525002c chore: remove agents experiment flag and mark feature as beta (#24432)
Remove the `ExperimentAgents` feature flag so the Agents feature is
always available without requiring `--experiments=agents`. The feature
is now in beta.

Existing deployments that still pass `--experiments=agents` will get a
harmless "ignoring unknown experiment" warning on startup.

### Changes

**Backend:**
- Remove `RequireExperimentWithDevBypass` middleware from chat and MCP
server routes
- Always include `AgentsAccessRole` in assignable site roles (later
refactored to org-scoped on main; rebase keeps that)
- Always set `AgentsTabVisible = true`, then drop the entire dead
`AgentsTabVisible` metadata pipeline (Go htmlState field,
populateHTMLState goroutine, HTML meta tag, useEmbeddedMetadata
registration, mock); no production consumer reads it. `AgentsNavItem`
already gates on `permissions.createChat`.
- Make `blob:` CSP `img-src` addition unconditional
- Remove `ExperimentAgents` constant, `DisplayName` case, and
`ExperimentsKnown` entry

**CLI:**
- Graduate the agents TUI from `coder exp agents` to `coder agents`
(moved from `AGPLExperimental()` to `CoreSubcommands()`)
- Drop the `agent` alias so it does not collide with the hidden
workspace-agent command
- Rename implementation files `cli/exp_agents_*.go` -> `cli/agents_*.go`
and internal identifiers (`expChatsTUIModel` -> `chatsTUIModel`,
`newExpChatsTUIModel` -> `newChatsTUIModel`, `setupExpAgentsBackend` ->
`setupAgentsBackend`, `startExpAgentsSession` -> `startAgentsSession`,
`expAgentsPtr` -> `agentsPtr`, `expAgentsSession` -> `agentsSession`,
`TestExpAgents*` -> `TestAgents*`). `expClient` (the
`*codersdk.ExperimentalClient` local) is kept; `coderd/exp_chats*.go`
and other still-experimental `cli/exp_*.go` commands are intentionally
untouched.

**Frontend:**
- Remove experiment check from `AgentsNavItem` - render when
`canCreateChat` is true
- Remove `agentsEnabled` experiment check from `WorkspacesPage`, then
gate `chatsByWorkspace` on `permissions.createChat` so users without
chat access don't trigger the per-page DB query (Copilot review
feedback)
- Add `FeatureStageBadge` (beta) next to the Coder logo in the Agents
sidebar (desktop + mobile)

**Docs:**
- Remove experiment flag setup instructions from `early-access.md` and
`getting-started.md` (and rename `early-access.md`'s "Enable Coder
Agents" heading to "Set up Coder Agents", since there is no enablement
step left)
- Update `chats-api.md` and `getting-started.md`'s Chats API note to say
"beta" instead of "experimental"
- `docs/manifest.json`: drop "experimental" from the Chats API sidebar
description
- `make gen` regenerated `docs/reference/cli/agents.md` and the CLI
index
- `scripts/check_emdash.sh`: exclude `cli/testdata/*.golden` and
`enterprise/cli/testdata/*.golden` from the new repo-wide emdash lint,
since serpent emits emdash borders in every generated `--help` golden
file

**Tests:**
- Remove `ExperimentAgents` setup from all test files (14 occurrences
across 7 files)
- Update stale "with the agents experiment" comments in
`coderd/x/chatd/integration_test.go` and `coderd/mcp_test.go`


<img width="1185" height="900" alt="image"
src="https://github.com/user-attachments/assets/b420bc8f-41d6-42c6-abd8-ad572533d651"
/>


> 🤖 Generated by Coder Agents
2026-05-01 01:49:00 +10:00

310 lines
8.0 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import type { Region, User } from "#/api/typesGenerated";
import {
MockAppearanceConfig,
MockBuildInfo,
MockEntitlements,
MockExperiments,
MockOrganization,
MockPermissions,
MockTasksTabVisible,
MockUserAppearanceSettings,
MockUserOwner,
} from "#/testHelpers/entities";
import {
DEFAULT_METADATA_KEY,
type MetadataKey,
MetadataManager,
type MetadataValue,
makeUseEmbeddedMetadata,
type RuntimeHtmlMetadata,
useEmbeddedMetadata,
} from "./useEmbeddedMetadata";
// Make sure that no matter what happens in the tests, all metadata is
// eventually deleted
const allAppendedNodes = new Set<Set<Element>>();
afterAll(() => {
for (const tracker of allAppendedNodes) {
for (const node of tracker) {
node.remove();
}
}
});
// Using empty array for now, because we don't have a separate mock regions
// value, but it's still good enough for the tests because it's truthy
const MockRegions: readonly Region[] = [];
const mockDataForTags = {
appearance: MockAppearanceConfig,
"build-info": MockBuildInfo,
entitlements: MockEntitlements,
experiments: MockExperiments,
user: MockUserOwner,
userAppearance: MockUserAppearanceSettings,
regions: MockRegions,
"tasks-tab-visible": MockTasksTabVisible,
permissions: MockPermissions,
organizations: [MockOrganization],
} as const satisfies Record<MetadataKey, MetadataValue>;
const emptyMetadata: RuntimeHtmlMetadata = {
appearance: {
available: false,
value: undefined,
},
"build-info": {
available: false,
value: undefined,
},
entitlements: {
available: false,
value: undefined,
},
experiments: {
available: false,
value: undefined,
},
regions: {
available: false,
value: undefined,
},
user: {
available: false,
value: undefined,
},
userAppearance: {
available: false,
value: undefined,
},
"tasks-tab-visible": {
available: false,
value: undefined,
},
permissions: {
available: false,
value: undefined,
},
organizations: {
available: false,
value: undefined,
},
};
const populatedMetadata: RuntimeHtmlMetadata = {
appearance: {
available: true,
value: MockAppearanceConfig,
},
"build-info": {
available: true,
value: MockBuildInfo,
},
entitlements: {
available: true,
value: MockEntitlements,
},
experiments: {
available: true,
value: MockExperiments,
},
regions: {
available: true,
value: MockRegions,
},
user: {
available: true,
value: MockUserOwner,
},
userAppearance: {
available: true,
value: MockUserAppearanceSettings,
},
"tasks-tab-visible": {
available: true,
value: MockTasksTabVisible,
},
permissions: {
available: true,
value: MockPermissions,
},
organizations: {
available: true,
value: [MockOrganization],
},
};
function seedInitialMetadata(metadataKey: string): () => void {
// Enforcing this to make sure that even if we start to adopt more concurrent
// tests through Vitest (or similar), there's no risk of global state causing
// weird, hard-to-test false positives/negatives with other tests
if (metadataKey === DEFAULT_METADATA_KEY) {
throw new Error(
"Please ensure that the key you provide does not match the key used throughout the majority of the application",
);
}
const trackedNodes = new Set<Element>();
allAppendedNodes.add(trackedNodes);
for (const metadataName in mockDataForTags) {
// Serializing first because that's the part that can fail; want to fail
// early if possible
const value = mockDataForTags[metadataName as keyof typeof mockDataForTags];
const serialized = JSON.stringify(value);
const newNode = document.createElement("meta");
newNode.setAttribute(metadataKey, metadataName);
newNode.setAttribute("content", serialized);
document.head.append(newNode);
trackedNodes.add(newNode);
}
return () => {
for (const node of trackedNodes) {
node.remove();
}
};
}
function renderMetadataHook(metadataKey: string) {
const manager = new MetadataManager(metadataKey);
const hook = makeUseEmbeddedMetadata(manager);
return {
...renderHook(hook),
manager,
};
}
// Just to be on the safe side, probably want to make sure that each test case
// is set up with a unique key
describe(useEmbeddedMetadata.name, () => {
it("Correctly detects when metadata is missing in the HTML page", () => {
const key = "cat";
// Deliberately avoid seeding any metadata
const { result } = renderMetadataHook(key);
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(emptyMetadata);
});
it("Can detect when metadata exists in the HTML", () => {
const key = "dog";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(
populatedMetadata,
);
cleanupTags();
});
it("Lets external systems (including React) subscribe to when metadata values are deleted", () => {
const key = "bird";
const tag1: MetadataKey = "user";
const tag2: MetadataKey = "appearance";
const cleanupTags = seedInitialMetadata(key);
const { result: reactResult, manager } = renderMetadataHook(key);
const nonReactSubscriber = vi.fn();
manager.subscribe(nonReactSubscriber);
const expectedUpdate1: RuntimeHtmlMetadata = {
...populatedMetadata,
[tag1]: {
available: false,
value: undefined,
},
};
// Test that updates work when run directly through the metadata manager
// itself
act(() => manager.clearMetadataByKey(tag1));
expect(reactResult.current.metadata).toEqual(expectedUpdate1);
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate1);
nonReactSubscriber.mockClear();
const expectedUpdate2: RuntimeHtmlMetadata = {
...expectedUpdate1,
[tag2]: {
available: false,
value: undefined,
},
};
// Test that updates work when calling the convenience function exposed
// through the React hooks
act(() => reactResult.current.clearMetadataByKey(tag2));
expect(reactResult.current.metadata).toEqual(expectedUpdate2);
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate2);
cleanupTags();
});
it("Will delete the original metadata node when you try deleting a metadata state value", () => {
const key = "aardvark";
const tagToDelete: MetadataKey = "user";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
const query = `meta[${key}=${tagToDelete}]`;
let userNode = document.querySelector(query);
expect(userNode).not.toBeNull();
act(() => result.current.clearMetadataByKey(tagToDelete));
userNode = document.querySelector(query);
expect(userNode).toBeNull();
cleanupTags();
});
it("Will not notify subscribers if you try deleting a metadata value that does not exist (or has already been deleted)", () => {
const key = "giraffe";
const tagToDelete: MetadataKey = "entitlements";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
act(() => result.current.clearMetadataByKey(tagToDelete));
const resultBeforeNoOp = result.current.metadata;
act(() => result.current.clearMetadataByKey(tagToDelete));
expect(result.current.metadata).toBe(resultBeforeNoOp);
cleanupTags();
});
// Need to guarantee this, or else we could have a good number of bugs in the
// React UI
it("Always treats metadata as immutable values during all deletions", () => {
const key = "hamster";
const tagToDelete: MetadataKey = "user";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
const initialResult = result.current.metadata;
act(() => result.current.clearMetadataByKey(tagToDelete));
const newResult = result.current.metadata;
expect(initialResult).not.toBe(newResult);
// Mutate the initial result, and make sure the change doesn't propagate to
// the updated result
const mutableUser = initialResult.user as {
available: boolean;
value: User | undefined;
};
mutableUser.available = false;
mutableUser.value = undefined;
expect(mutableUser).toEqual(newResult.user);
expect(mutableUser).not.toBe(newResult.user);
cleanupTags();
});
});