Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions e2e/scenarios/policies-duplicate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Cross-target (browser): the row's Duplicate menu prefills the add form
// with the source row's pattern, action, and owner, and focuses the pattern
// input with its content selected so the user can tweak the pattern in one
// keystroke. Submitting the form then writes the duplicated rule as its own
// row. The product guarantees this scenario pins:
//
// 1. The Duplicate menu item exists on every row's overflow menu.
// 2. Clicking it copies the row's `pattern` and `action` into the add form
// (the action select reflects the source row's verb label).
// 3. The pattern input is focused after the prefill, the UX promise that
// "I can just type" instead of "I have to click the input first".
// 4. After editing the pattern and submitting, BOTH rows appear in the
// list and the server-side `policies.list` reflects both.
//
// This is the only UI surface today that exercises the form's `prefill`
// prop. Regressions to the prefill nonce, the focus timing, or the field
// copy logic all surface as a `waitFor` timeout in this scenario.
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";

import { scenario } from "../src/scenario";
import { Api, Browser, Target } from "../src/services";

const coreApi = composePluginApi([] as const);

scenario(
"Policies · the row's Duplicate menu prefills the add form for a one-keystroke clone",
{ timeout: 120_000 },
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const { client: apiClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* apiClient(coreApi, identity);

const suffix = randomBytes(4).toString("hex");
const prefix = `policies-dup-${suffix}.`;
const originalPattern = `${prefix}alpha`;
const copyPattern = `${prefix}beta`;

const cleanup = Effect.gen(function* () {
const policies = yield* client.policies.list().pipe(Effect.orElseSucceed(() => []));
yield* Effect.forEach(
policies.filter((p) => p.pattern.startsWith(prefix)),
(p) =>
client.policies
.remove({ params: { policyId: p.id }, payload: { owner: p.owner } })
.pipe(Effect.ignore),
);
}).pipe(Effect.ignore);

yield* Effect.gen(function* () {
yield* browser.session(identity, async ({ page, step }) => {
const patternInput = page.getByPlaceholder("vercel.dns.* or *");
const formActionSelect = page.locator("form").getByRole("combobox").first();
const cardContent = page.locator('[data-slot="card-stack-content"]');
const row = (text: string) =>
cardContent.locator('[data-slot="card-stack-entry"]').filter({ hasText: text });

await step("Open the policies page and add a Block rule", async () => {
await page.goto("/policies", { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "Policies", exact: true }).waitFor();
await patternInput.fill(originalPattern);
// Switch the form's action to Block so the duplicate prefill has a
// non-default action to verify.
await formActionSelect.click();
await page.getByRole("option", { name: "Block", exact: true }).click();
await page.getByRole("button", { name: "Add policy", exact: true }).click();
await row(originalPattern).waitFor();
// Settle the POST before the next step opens a menu, per the e2e
// style guide ("settle the network before opening menus"). The
// optimistic render attaches the row immediately, but Radix's
// pointer listeners on the overflow trigger only bind cleanly on
// the post-confirm re-render. Without this wait, a fast hover+click
// can land on a stale trigger and the dropdown silently no-ops.
await page.waitForLoadState("networkidle");
});

await step("Open the row's overflow menu and click Duplicate", async () => {
// Hover the row to materialize the opacity-0 overflow trigger, then
// target it by data-slot rather than a positional `getByRole`,
// the row also contains the badge's role="combobox" trigger, which
// can change the last-button heuristic if the DOM order ever moves.
await row(originalPattern).hover();
const trigger = row(originalPattern).locator('[data-slot="dropdown-menu-trigger"]');
// Wait for the trigger to be visible (group-hover transition is
// opacity-based), then click without `force`, matching the
// policies-round-trip overflow pattern. The selfhost dev server
// boot can be slow enough that a force-click races the trigger's
// opacity transition and the Radix open handler never fires.
await trigger.waitFor({ state: "visible" });
await trigger.click();
// The DropdownMenuContent is portaled to body, not the row; wait
// for it to mount before targeting the menu item, so a timeout
// here means "the menu never opened", not "the item is missing".
const menu = page.locator('[data-slot="dropdown-menu-content"]');
await menu.waitFor();
await menu.getByRole("menuitem", { name: "Duplicate", exact: true }).click();
});

await step("The form prefilled with the source pattern and action", async () => {
expect(
await patternInput.inputValue(),
"the pattern input carries the source row's pattern verbatim",
).toBe(originalPattern);
expect(
await formActionSelect.textContent(),
"the action select carries the source row's verb label",
).toContain("Block");
});

await step("The pattern input is focused, ready to be tweaked", async () => {
// Asserting on the focused element's id rather than a boolean
// identity check, a regression where focus lands on the wrong
// element will print the actual id instead of bare `false`, which
// is the e2e/AGENTS.md "values not booleans" rule.
await expect
.poll(
() =>
patternInput.evaluate(
() => (document.activeElement as HTMLElement | null)?.id ?? "",
),
{
message: "Duplicate focuses the pattern input by id",
timeout: 2_000,
},
)
.toBe("policy-pattern");
});

await step("Tweak the pattern and submit the copy", async () => {
await patternInput.fill(copyPattern);
await page.getByRole("button", { name: "Add policy", exact: true }).click();
await row(copyPattern).waitFor();
});

await step("Both rows appear in the rendered list", async () => {
await row(originalPattern).waitFor();
await row(copyPattern).waitFor();
});
});

// Server-side truth on a fresh read: both rules persisted, both
// org-owned, both carrying the action the form was holding at submit
// (Block was set before the first add and the prefill preserved it).
const policies = yield* client.policies.list();
const mine = policies
.filter((p) => p.pattern.startsWith(prefix))
.map((p) => `${p.owner} ${p.pattern} ${p.action}`)
.sort();
expect(mine, "both duplicated rules persisted with the source's action").toEqual(
[`org ${originalPattern} block`, `org ${copyPattern} block`].sort(),
);
}).pipe(Effect.ensuring(cleanup));
}),
);
79 changes: 79 additions & 0 deletions e2e/scenarios/policies-landing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Cross-target (browser): a fresh workspace lands on `/policies` with an
// explainer empty state and a gated add form. The existing `policies-ui`
// scenario covers authoring rules from the tool tree; this scenario only
// pins the landing surface for a workspace that has never authored a rule.
//
// Asserts on:
//
// 1. The page renders the "Policies" heading and the rationale paragraph
// under it (scoped to its `<p>`, not a bare text match).
// 2. The Active policies card-stack header is present, with the empty-
// state explainer reading "No policies yet. Tools fall back to their
// plugin's default approval behavior.", the product's guarantee that
// absence-of-rule is a resolved default, not a loading state.
// 3. The add-policy form's pattern input exists and the submit button is
// gated. Asserted as a value read of the `disabled` attribute, a
// regression prints the actual element state instead of `false`.
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";

scenario(
"Policies · a fresh workspace lands on an explainer empty state with a gated add form",
{ timeout: 90_000 },
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const identity = yield* target.newIdentity();

yield* browser.session(identity, async ({ page, step }) => {
await step("Open the policies page on a fresh workspace", async () => {
await page.goto("/policies", { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "Policies", exact: true }).waitFor();
});

await step("The rationale paragraph explains what policies do", async () => {
await page
.locator("p")
.filter({
hasText:
"Override default approval behavior for tools. The most restrictive matched action wins.",
})
.waitFor();
});

await step("The Active policies card stack carries the empty-state explainer", async () => {
await page
.locator('[data-slot="card-stack-header"]')
.filter({ hasText: "Active policies" })
.waitFor();
// Scope to the card stack's content area: a regression where the
// explainer text leaks out of the empty state into a row body would
// still satisfy a bare `getByText`.
await page
.locator('[data-slot="card-stack-content"]')
.getByText(
"No policies yet. Tools fall back to their plugin's default approval behavior.",
{ exact: true },
)
.waitFor();
});

await step("The add form is reachable and its submit is gated", async () => {
const patternInput = page.getByPlaceholder("vercel.dns.* or *");
await patternInput.waitFor();
const addButton = page.getByRole("button", { name: "Add policy", exact: true });
await addButton.waitFor();
// Read the actual `disabled` attribute, a present attribute serializes
// as the empty string; a regression that ungates the button drops the
// attribute and this reads back as `null`.
expect(
await addButton.getAttribute("disabled"),
"Add policy is disabled until a valid pattern is typed",
).toBe("");
});
});
}),
);
106 changes: 106 additions & 0 deletions e2e/scenarios/policies-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Cross-target: the full policies CRUD round-trip through the typed
// HttpApiClient. The existing `policies.test.ts` scenario pins create + list
// only, the gaps this scenario closes are `update` (both full-payload and
// action-only partial, the shape the row badge's `handleUpdate` sends) and
// `remove`. Asserts on:
//
// 1. The create response carries a non-empty `position` (the fractional-
// indexing key the policies page's sort and reorder math both depend
// on, a regression that drops it would silently break ordering).
// 2. A full update returns the new pattern + action; a subsequent partial
// update (action only, no pattern in the payload) flips the action and
// leaves the pattern intact.
// 3. The list reflects the latest server-side values between writes.
// 4. After remove, list returns success and does NOT contain the id,
// asserted as `expect(ids).not.toContain(created.id)` so a regression
// prints the leaked ids instead of `false`.
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";

import { scenario } from "../src/scenario";
import { Api, Target } from "../src/services";

const coreApi = composePluginApi([] as const);

scenario(
"Policies · an existing policy can be re-targeted, partially edited, and removed",
{},
Effect.gen(function* () {
const target = yield* Target;
const { client: apiClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* apiClient(coreApi, identity);

// Selfhost shares one bootstrap-admin workspace across scenarios, so
// every pattern carries a per-run suffix and the finalizer removes any
// row carrying it, even if a mid-test failure skips the explicit remove.
const suffix = randomBytes(4).toString("hex");
const prefix = `policies-lc-${suffix}.`;
const initialPattern = `${prefix}alpha`;
const renamedPattern = `${prefix}beta`;

const cleanup = Effect.gen(function* () {
const policies = yield* client.policies.list().pipe(Effect.orElseSucceed(() => []));
yield* Effect.forEach(
policies.filter((p) => p.pattern.startsWith(prefix)),
(p) =>
client.policies
.remove({ params: { policyId: p.id }, payload: { owner: p.owner } })
.pipe(Effect.ignore),
);
}).pipe(Effect.ignore);

yield* Effect.gen(function* () {
const created = yield* client.policies.create({
payload: { owner: "org", pattern: initialPattern, action: "block" },
});
expect(created.pattern, "create response echoes the requested pattern").toBe(initialPattern);
expect(created.action, "create response echoes the requested action").toBe("block");
// The page's sort and reorder math both depend on a non-empty position;
// a regression that ever leaves it blank would silently break ordering
// without raising on any single create.
expect(created.position, "create response carries a fractional-indexing key").not.toBe("");

// Full payload: pattern AND action change in one update, the path the
// page itself never sends today, but `policies.update` advertises.
const renamed = yield* client.policies.update({
params: { policyId: created.id },
payload: { owner: "org", pattern: renamedPattern, action: "approve" },
});
expect(renamed.pattern, "full update applied the new pattern").toBe(renamedPattern);
expect(renamed.action, "full update applied the new action").toBe("approve");

// Partial payload: action only, no pattern, the exact shape the row
// badge's `handleUpdate` sends. The server should flip the action and
// leave the pattern intact.
const switched = yield* client.policies.update({
params: { policyId: created.id },
payload: { owner: "org", action: "require_approval" },
});
expect(switched.action, "partial update flipped the action").toBe("require_approval");
expect(switched.pattern, "partial update preserved the pattern").toBe(renamedPattern);

// List reflects the latest values between writes.
const afterEdit = yield* client.policies.list();
const myEntry = afterEdit.find((p) => p.id === created.id);
expect(myEntry, "the edited row appears in list with the latest values").toMatchObject({
pattern: renamedPattern,
action: "require_approval",
});

yield* client.policies.remove({
params: { policyId: created.id },
payload: { owner: "org" },
});

const afterRemove = yield* client.policies.list();
expect(
afterRemove.map((p) => p.id),
"the removed id is gone from the list",
).not.toContain(created.id);
}).pipe(Effect.ensuring(cleanup));
}),
);
Loading