@terreno/blocks 57.20.0 → 57.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/biome.jsonc +1 -0
- package/e2e/smoke.ts +96 -0
- package/package.json +6 -4
- package/src/asks/docParity.test.ts +1 -9
- package/src/asks/jsonSchema.test.ts +3 -3
- package/src/asks/schema.test.ts +9 -4
- package/src/asks/simpleCard.test.ts +25 -24
- package/src/asks/validateInput.test.ts +2 -1
- package/src/blocksDocParity.test.ts +1 -1
- package/src/cli.test.ts +7 -7
- package/src/golden.test.ts +1 -1
- package/src/jsonSchema.test.ts +1 -1
- package/src/parse.test.ts +13 -13
- package/src/parsePartial.test.ts +1 -1
- package/src/tests/askFixtures.ts +10 -2
- package/tsconfig.typecheck.json +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -20,6 +20,56 @@ Upgrade notes for consumer action live in [`mcp-server/src/docs/upgrades/`](mcp-
|
|
|
20
20
|
|
|
21
21
|
Unreleased changes live in [`changelog/unreleased/`](changelog/unreleased/). Add one Markdown file per feature (see that directory's README) instead of editing this section.
|
|
22
22
|
|
|
23
|
+
## [57.21.0] - 2026-11-01
|
|
24
|
+
|
|
25
|
+
Upgrade note: [`mcp-server/src/docs/upgrades/57.21.0.md`](mcp-server/src/docs/upgrades/57.21.0.md).
|
|
26
|
+
|
|
27
|
+
### Breaking
|
|
28
|
+
|
|
29
|
+
- The `terreno-scan` plugin is now `terreno-loop`, and its skills are renamed from `terreno-scan-*` to `terreno-loop-*`. The resident loop `terreno-scan-loop` is now `terreno-loop-run`: `/terreno-loop:run` in Claude Code, `/terreno-loop-run` in Cursor, `$terreno-loop-run` in Codex. Claude Code moves existing installs over through the marketplace `renames` entry. In Cursor and Codex, uninstall `terreno-scan` and install `terreno-loop`. Campaign state stays at `.terreno/scan/<goal>.json`, so campaigns already in progress resume.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `@terreno/admin-spa` serves an MCP OAuth consent screen at `/console/oauth/consent`, so a backend with no product frontend can set `mcpOAuth.consentUrl` to `${issuer}/console/oauth/consent`. Any signed-in user can approve or deny a request with their Better Auth session; signed-out users sign in and return through the login screen's new `returnTo` parameter, which accepts only in-app paths.
|
|
34
|
+
- Claude Code plugins now publish to npm with every release, as `@terreno/agent-plugin` and `@terreno/agent-plugin-loop`. A new `terreno-releases` marketplace installs them at the latest stable release. To pin one release, add `TerrenoLabs/terreno#<version>` instead. See [Install agent skills](docs/how-to/install-agent-skills.md#pick-a-channel).
|
|
35
|
+
- The generated Claude plugin manifest no longer pins `version`. Before this change, plugins installed from the `TerrenoLabs/terreno` marketplace stayed on the `2.12.0` copy, because their version never changed. They now update on every commit. Run `/plugin update terreno@terreno-plugins` once to leave `2.12.0`.
|
|
36
|
+
- The `terreno-planning` lifecycle plugin (2.13.0) plans e2e tests and manual regression cases in every IP:
|
|
37
|
+
|
|
38
|
+
- Grow requires `## E2E tests` and `## Regression cases` sections (either may be `None — <reason>`), read from the e2e guide at `docs/testing/e2e.md` or a `## Testing conventions` override in AGENTS.md or CLAUDE.md.
|
|
39
|
+
- Regression cases stay minimal: most features add none. A case is only for what a developer or QA must check by hand, such as push notifications, and Roast fails one that repeats an existing case.
|
|
40
|
+
- Pick writes each planned spec in the task that delivers it, and Roast runs it; a missing or failing row is `FAIL`.
|
|
41
|
+
- Brew gates on every planned spec and merges regression cases into `docs/regression/<area>.md` with the next free ID.
|
|
42
|
+
- Taste re-checks both sections after behaviour-changing fixes and renumbers regression ID collisions with `master`.
|
|
43
|
+
- `bun run check:lifecycle-skills` enforces these phrases in every stage.
|
|
44
|
+
- New `backfill-regression` skill proposes manual regression cases one area at a time and writes only the sets the human approves.
|
|
45
|
+
- A consumer opts out of manual regression suites with `Regression suites | none` in its `## Testing conventions` table.
|
|
46
|
+
- The `terreno-planning` lifecycle plugin (2.14.0) grows an end-user guide with every feature:
|
|
47
|
+
|
|
48
|
+
- Every IP has a `## User guide` section listing the guide pages it adds, updates, or retires, or `None — <reason>`. Pages live in `docs/user-guide/<area>/<feature>.md`, one feature per page, with `title`, `audience`, `platforms`, and `questions` frontmatter that chat agents use to answer "how do I…" questions.
|
|
49
|
+
- Pick writes each page in the task that ships the feature, Roast checks it against the shipped screens, Brew gates on it and the index, and Taste keeps it current after fixes.
|
|
50
|
+
- A repo opts out with `| User guide | none — <reason> |` in a `## User guide conventions` table in AGENTS.md or CLAUDE.md.
|
|
51
|
+
- New `backfill-user-guide` skill drafts pages for features that shipped without one, one area at a time, and writes only the pages the human approves.
|
|
52
|
+
- `bun run check:user-guide` validates the pages and index (CI `repo-policies`); `--uncovered <app dir>` lists screens no page names.
|
|
53
|
+
- Terreno's own guide covers the example app's main features.
|
|
54
|
+
- `@terreno/api`: opt-in `mcpRequestLog` on `TerrenoApp` records one metadata-only `McpRequestLog` row per MCP tool call (tool, user, credential, service token or OAuth client, duration, outcome, error class; never arguments, results, or error text), expiring after `retentionDays` (default 30).
|
|
55
|
+
- `@terreno/api`: opt-in `mcpToolSettings` on `TerrenoApp` registers every MCP tool in `McpToolSetting` at startup; tools an admin switches off are hidden from `tools/list` and rejected by `tools/call`.
|
|
56
|
+
- `@terreno/api`: deleting an `McpOAuthToken` row revokes its whole connection (token family) and keeps the rows. New `McpOAuthToken.revokeAllForUser` and `McpServiceToken.revokeAllForUser`.
|
|
57
|
+
- `@terreno/admin-backend`: `mcpAdminModels()` adds MCP admin screens (service tokens, OAuth clients, OAuth connections, request log, tool switches), `mcpAdminStatements` for RBAC, and `mcpAdminScripts()` adds `mcp-sign-out-everywhere`. See [Manage MCP from the admin panel](docs/how-to/manage-mcp-in-admin.md).
|
|
58
|
+
- Docs: [Expose an MCP server safely](docs/how-to/expose-mcp-server-safely.md) (OAuth, service tokens, Cloudflare Tunnel, Access) and the `bootstrap-mcp-server` skill for a single-binary MCP server with the admin console embedded.
|
|
59
|
+
- Every workspace package has a real-process smoke test and an e2e page:
|
|
60
|
+
|
|
61
|
+
- Each package's `e2e/smoke.ts` (or `smoke.spec.ts` for browser packages) runs its built output through its public entry: a server on a real port, the compiled binary, or a page in Chromium. `bun run --cwd <package> smoke` runs one; `bun run smoke` runs them all and prints each package's time. The CircleCI `smoke` job runs them on every package change.
|
|
62
|
+
- `@terreno/test` adds the smoke helpers `smokeMongoUri()`, `spawnUntilReady()`, `spawnExampleBackend()`, `runBin()`, and `freePort()`.
|
|
63
|
+
- The [E2E testing guide](docs/testing/e2e.md) explains the test tiers and links one page per package under `docs/testing/e2e/`.
|
|
64
|
+
- Manual regression cases live in `docs/regression/<area>.md`, one file per area, and `bun run check:regression-suites` validates them.
|
|
65
|
+
- example-frontend's smoke serves a web export that `bun run --cwd example-frontend export:smoke` builds once with a placeholder API URL; the smoke swaps in a `freePort()` API URL as it serves the bundle, so the 30 s smoke no longer rebuilds the export. `spawnUntilReady()` takes `stopGraceMs`, and `spawnExampleBackend()` stops example-backend after 0.5 s instead of 5 s.
|
|
66
|
+
- Every workspace package has a `typecheck` script that runs `tsc` over source and test files through a new `tsconfig.typecheck.json`. Root `bun run lint` ends with `bun run typecheck`, and CircleCI runs it in a new `typecheck` job. Test files that had type errors were fixed.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- `@terreno/api`: deleting an `McpOAuthToken` row with document or query `deleteOne` now revokes that row's whole token family (the connection) and keeps the rows instead of removing one pair. `deleteMany` is unchanged.
|
|
71
|
+
- `@terreno/ui`: new `theme.field` tokens (`background`, `backgroundDisabled`, `border`, `borderFocus`, `borderError`, `borderDisabled`) style form inputs. `TextField`, `SelectField`, `DateTimeField`, `HeightField`, and `OtpField` read them instead of `surface.base` and `border.dark`. Defaults equal the old colors, but overriding `surface` or `border` no longer recolors those fields; use `setTheme({field: ...})`.
|
|
72
|
+
|
|
23
73
|
## [57.20.0] - 2026-10-30
|
|
24
74
|
|
|
25
75
|
Upgrade note: [`mcp-server/src/docs/upgrades/57.20.0.md`](mcp-server/src/docs/upgrades/57.20.0.md).
|
package/biome.jsonc
CHANGED
package/e2e/smoke.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-process smoke for @terreno/blocks. Run `bun e2e/smoke.ts` after `bun run compile`.
|
|
3
|
+
*
|
|
4
|
+
* Finds the compiled `terreno-blocks` binary through the installed package's `bin` field, the
|
|
5
|
+
* way a consumer's package manager links it, and runs `validate` on two documents written to a
|
|
6
|
+
* temp dir: a valid one must exit 0 with no output, and an invalid one must exit non-zero and
|
|
7
|
+
* name the failing path and error code. Exits non-zero on the first failed check.
|
|
8
|
+
*/
|
|
9
|
+
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
|
10
|
+
import {tmpdir} from "node:os";
|
|
11
|
+
import {dirname, join} from "node:path";
|
|
12
|
+
import {runBin} from "@terreno/test";
|
|
13
|
+
|
|
14
|
+
const BIN_NAME = "terreno-blocks";
|
|
15
|
+
|
|
16
|
+
const VALID_DOCUMENT = `v: 1
|
|
17
|
+
blocks:
|
|
18
|
+
- type: text
|
|
19
|
+
markdown: Signups grew 12% this month.
|
|
20
|
+
- type: actions
|
|
21
|
+
id: followups
|
|
22
|
+
elements:
|
|
23
|
+
- type: button
|
|
24
|
+
id: weekly
|
|
25
|
+
text: Show weekly
|
|
26
|
+
action:
|
|
27
|
+
kind: reply
|
|
28
|
+
text: Show weekly signups
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
// The text block has no `markdown`, so validation must report MISSING_REQUIRED at that path.
|
|
32
|
+
const INVALID_DOCUMENT = `v: 1
|
|
33
|
+
blocks:
|
|
34
|
+
- type: text
|
|
35
|
+
`;
|
|
36
|
+
const EXPECTED_ERROR_PATH = "blocks[0].markdown";
|
|
37
|
+
const EXPECTED_ERROR_CODE = "MISSING_REQUIRED";
|
|
38
|
+
|
|
39
|
+
/** Resolves the installed package by name, then follows its `bin` field to the compiled CLI. */
|
|
40
|
+
const resolveBlocksBin = (): string => {
|
|
41
|
+
const packageDir = dirname(dirname(require.resolve("@terreno/blocks")));
|
|
42
|
+
const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) as {
|
|
43
|
+
bin?: Record<string, string>;
|
|
44
|
+
};
|
|
45
|
+
const relativeBin = manifest.bin?.[BIN_NAME];
|
|
46
|
+
if (!relativeBin) {
|
|
47
|
+
throw new Error(`@terreno/blocks package.json has no "${BIN_NAME}" bin`);
|
|
48
|
+
}
|
|
49
|
+
return join(packageDir, relativeBin);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const run = async (): Promise<void> => {
|
|
53
|
+
const startedAt = performance.now();
|
|
54
|
+
const bin = resolveBlocksBin();
|
|
55
|
+
const workDir = mkdtempSync(join(tmpdir(), "blocks-smoke-"));
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const validPath = join(workDir, "valid.yaml");
|
|
59
|
+
writeFileSync(validPath, VALID_DOCUMENT);
|
|
60
|
+
const valid = await runBin({args: ["validate", validPath], bin});
|
|
61
|
+
if (valid.exitCode !== 0 || valid.stdout !== "" || valid.stderr !== "") {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${BIN_NAME} validate (valid): expected exit 0 with no output, got exit ${valid.exitCode}\nstdout: ${valid.stdout}\nstderr: ${valid.stderr}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
console.info(`✅ ${BIN_NAME} validate accepts a valid document with exit 0`);
|
|
67
|
+
|
|
68
|
+
const invalidPath = join(workDir, "invalid.yaml");
|
|
69
|
+
writeFileSync(invalidPath, INVALID_DOCUMENT);
|
|
70
|
+
const invalid = await runBin({args: ["validate", invalidPath], bin});
|
|
71
|
+
const isErrorReported =
|
|
72
|
+
invalid.stdout.includes(EXPECTED_ERROR_PATH) && invalid.stdout.includes(EXPECTED_ERROR_CODE);
|
|
73
|
+
if (invalid.exitCode === 0 || !isErrorReported) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${BIN_NAME} validate (invalid): expected a non-zero exit naming ${EXPECTED_ERROR_PATH} ${EXPECTED_ERROR_CODE}, got exit ${invalid.exitCode}\nstdout: ${invalid.stdout}\nstderr: ${invalid.stderr}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
console.info(
|
|
79
|
+
`✅ ${BIN_NAME} validate rejects an invalid document with exit ${invalid.exitCode} and ${EXPECTED_ERROR_CODE}`
|
|
80
|
+
);
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(workDir, {force: true, recursive: true});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.info(`blocks smoke passed in ${Math.round(performance.now() - startedAt)} ms`);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
run().then(
|
|
89
|
+
() => {
|
|
90
|
+
process.exit(0);
|
|
91
|
+
},
|
|
92
|
+
(error: unknown) => {
|
|
93
|
+
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
);
|
package/package.json
CHANGED
|
@@ -47,14 +47,16 @@
|
|
|
47
47
|
"compile": "bun tsc",
|
|
48
48
|
"compile:watch": "bun tsc -w",
|
|
49
49
|
"dev": "bun tsc -w",
|
|
50
|
-
"lint": "biome check ./src ./scripts",
|
|
51
|
-
"lint:fix": "biome check --write ./src ./scripts",
|
|
50
|
+
"lint": "biome check ./src ./scripts ./e2e",
|
|
51
|
+
"lint:fix": "biome check --write ./src ./scripts ./e2e",
|
|
52
52
|
"schemas": "bun scripts/writeJsonSchemas.ts",
|
|
53
|
+
"smoke": "bun e2e/smoke.ts",
|
|
53
54
|
"test": "bun test",
|
|
54
55
|
"test:agent": "AGENT=1 bun test --only-failures",
|
|
55
56
|
"test:ci": "bun test",
|
|
56
|
-
"test:coverage": "bun run ../scripts/check-coverage.ts"
|
|
57
|
+
"test:coverage": "bun run ../scripts/check-coverage.ts",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.typecheck.json"
|
|
57
59
|
},
|
|
58
60
|
"types": "dist/index.d.ts",
|
|
59
|
-
"version": "57.
|
|
61
|
+
"version": "57.21.0"
|
|
60
62
|
}
|
|
@@ -4,15 +4,7 @@ import {join} from "node:path";
|
|
|
4
4
|
import {ASK_ERROR_CODES} from "./errors";
|
|
5
5
|
import {ASK_LIMITS} from "./limits";
|
|
6
6
|
|
|
7
|
-
const REFERENCE_PATH = join(
|
|
8
|
-
import.meta.dir,
|
|
9
|
-
"..",
|
|
10
|
-
"..",
|
|
11
|
-
"..",
|
|
12
|
-
"docs",
|
|
13
|
-
"reference",
|
|
14
|
-
"agent-ui-asks.md"
|
|
15
|
-
);
|
|
7
|
+
const REFERENCE_PATH = join(__dirname, "..", "..", "..", "docs", "reference", "agent-ui-asks.md");
|
|
16
8
|
|
|
17
9
|
const reference = readFileSync(REFERENCE_PATH, "utf8");
|
|
18
10
|
|
|
@@ -2,12 +2,12 @@ import {describe, expect, it} from "bun:test";
|
|
|
2
2
|
import {readdirSync, readFileSync} from "node:fs";
|
|
3
3
|
import {join} from "node:path";
|
|
4
4
|
import {z} from "zod";
|
|
5
|
-
import {validAskFixtures} from "../tests/askFixtures";
|
|
5
|
+
import {fixtureAsk, validAskFixtures} from "../tests/askFixtures";
|
|
6
6
|
import {pendingAskListSchema, turnRequestSchema, turnResultSchema} from "./headless";
|
|
7
7
|
import {askJsonSchemas} from "./jsonSchema";
|
|
8
8
|
import {simpleCardSchema, toSimpleCard} from "./simpleCard";
|
|
9
9
|
|
|
10
|
-
const SCHEMAS_DIR = join(
|
|
10
|
+
const SCHEMAS_DIR = join(__dirname, "..", "..", "schemas");
|
|
11
11
|
|
|
12
12
|
const documents = askJsonSchemas();
|
|
13
13
|
|
|
@@ -21,7 +21,7 @@ const fromDocument = (fileName: string): z.ZodType => {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
const CARDS = validAskFixtures().map((fixture) =>
|
|
24
|
-
toSimpleCard({
|
|
24
|
+
toSimpleCard({...fixtureAsk(fixture), toolCallId: "call_fixture"})
|
|
25
25
|
);
|
|
26
26
|
|
|
27
27
|
const [CARD] = CARDS;
|
package/src/asks/schema.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
askInputSchemaFor,
|
|
10
10
|
askKindsForSurface,
|
|
11
11
|
COMPACT_ASK_KINDS,
|
|
12
|
+
type ConfirmAskInput,
|
|
12
13
|
choiceAskInputSchema,
|
|
13
14
|
compactChoiceAskInputSchema,
|
|
14
15
|
confirmAskInputSchema,
|
|
@@ -190,9 +191,13 @@ describe("markdownLengthBounds", () => {
|
|
|
190
191
|
|
|
191
192
|
describe("confirmButtonLabels", () => {
|
|
192
193
|
it("uses the ask's labels, defaulting to Confirm and Cancel", () => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
const plain: ConfirmAskInput = {prompt: "Go?"};
|
|
195
|
+
const labeled: ConfirmAskInput = {
|
|
196
|
+
confirmLabel: "Delete 14 todos",
|
|
197
|
+
denyLabel: "Keep them",
|
|
198
|
+
prompt: "Go?",
|
|
199
|
+
};
|
|
200
|
+
expect(confirmButtonLabels(plain)).toEqual({confirm: "Confirm", deny: "Cancel"});
|
|
201
|
+
expect(confirmButtonLabels(labeled)).toEqual({confirm: "Delete 14 todos", deny: "Keep them"});
|
|
197
202
|
});
|
|
198
203
|
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import {describe, expect, it} from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
fixtureAsk,
|
|
4
|
+
type ValidAskFixture,
|
|
5
|
+
validAskFixtures,
|
|
6
|
+
validAskFixturesOf,
|
|
7
|
+
} from "../tests/askFixtures";
|
|
3
8
|
import {ASK_LIMITS} from "./limits";
|
|
4
9
|
import type {
|
|
5
10
|
ChoiceAskInput,
|
|
@@ -192,9 +197,9 @@ const expectedCard = (fixture: ValidAskFixture): {buttons: ExpectedButton[]; han
|
|
|
192
197
|
describe("toSimpleCard golden fixtures", () => {
|
|
193
198
|
for (const fixture of validAskFixtures()) {
|
|
194
199
|
it(`derives the expected card for valid/${fixture.name}`, () => {
|
|
195
|
-
expect(
|
|
196
|
-
|
|
197
|
-
)
|
|
200
|
+
expect(toSimpleCard({...fixtureAsk(fixture), toolCallId: TOOL_CALL_ID})).toEqual(
|
|
201
|
+
fixture.simple
|
|
202
|
+
);
|
|
198
203
|
});
|
|
199
204
|
}
|
|
200
205
|
});
|
|
@@ -202,11 +207,7 @@ describe("toSimpleCard golden fixtures", () => {
|
|
|
202
207
|
describe("toSimpleCard properties over every valid fixture", () => {
|
|
203
208
|
for (const fixture of validAskFixtures()) {
|
|
204
209
|
describe(`valid/${fixture.name}`, () => {
|
|
205
|
-
const card = toSimpleCard({
|
|
206
|
-
input: fixture.input,
|
|
207
|
-
kind: fixture.kind,
|
|
208
|
-
toolCallId: TOOL_CALL_ID,
|
|
209
|
-
});
|
|
210
|
+
const card = toSimpleCard({...fixtureAsk(fixture), toolCallId: TOOL_CALL_ID});
|
|
210
211
|
|
|
211
212
|
it("passes simpleCardSchema", () => {
|
|
212
213
|
expect(simpleCardSchema.safeParse(card).success).toBe(true);
|
|
@@ -232,9 +233,9 @@ describe("toSimpleCard properties over every valid fixture", () => {
|
|
|
232
233
|
it("has the buttons and handoff of its rule-table row", () => {
|
|
233
234
|
const expected = expectedCard(fixture);
|
|
234
235
|
expect(card.handoff).toBe(expected.handoff);
|
|
235
|
-
expect(
|
|
236
|
-
|
|
237
|
-
);
|
|
236
|
+
expect<ExpectedButton[]>(
|
|
237
|
+
card.buttons.map(({id, response, style}) => ({id, response, style}))
|
|
238
|
+
).toEqual(expected.buttons);
|
|
238
239
|
});
|
|
239
240
|
|
|
240
241
|
it("never shows two buttons with the same label", () => {
|
|
@@ -442,9 +443,9 @@ describe("toSimpleCard properties over compact asks", () => {
|
|
|
442
443
|
|
|
443
444
|
it("follows the optionsFit row of the rule table", () => {
|
|
444
445
|
for (const {card, input} of cards) {
|
|
445
|
-
expect(
|
|
446
|
-
|
|
447
|
-
);
|
|
446
|
+
expect<ExpectedButton[]>(
|
|
447
|
+
card.buttons.map(({id, response, style}) => ({id, response, style}))
|
|
448
|
+
).toEqual(buttonsForRow(input, "optionsFit"));
|
|
448
449
|
}
|
|
449
450
|
});
|
|
450
451
|
|
|
@@ -744,9 +745,9 @@ describe("toSimpleCard confirm rules", () => {
|
|
|
744
745
|
|
|
745
746
|
it("puts approve first and deny last, with no Skip even when the ask allows declining", () => {
|
|
746
747
|
for (const {card, input} of cards) {
|
|
747
|
-
expect(
|
|
748
|
-
|
|
749
|
-
);
|
|
748
|
+
expect<ExpectedButton[]>(
|
|
749
|
+
card.buttons.map(({id, response, style}) => ({id, response, style}))
|
|
750
|
+
).toEqual(confirmButtons(input));
|
|
750
751
|
}
|
|
751
752
|
});
|
|
752
753
|
|
|
@@ -841,9 +842,9 @@ describe("toSimpleCard markdown rules", () => {
|
|
|
841
842
|
|
|
842
843
|
it("offers Approve draft exactly when the draft meets the length rules, then Cancel", () => {
|
|
843
844
|
for (const {card, fits, input} of cards) {
|
|
844
|
-
expect(
|
|
845
|
-
|
|
846
|
-
);
|
|
845
|
+
expect<ExpectedButton[]>(
|
|
846
|
+
card.buttons.map(({id, response, style}) => ({id, response, style}))
|
|
847
|
+
).toEqual(markdownButtons(input, fits));
|
|
847
848
|
}
|
|
848
849
|
});
|
|
849
850
|
|
|
@@ -935,9 +936,9 @@ describe("toSimpleCard form rules", () => {
|
|
|
935
936
|
|
|
936
937
|
it("offers Submit defaults exactly when there is a default and every required field has one", () => {
|
|
937
938
|
for (const {card, input, values} of cards) {
|
|
938
|
-
expect(
|
|
939
|
-
|
|
940
|
-
);
|
|
939
|
+
expect<ExpectedButton[]>(
|
|
940
|
+
card.buttons.map(({id, response, style}) => ({id, response, style}))
|
|
941
|
+
).toEqual(formButtons(input, values));
|
|
941
942
|
}
|
|
942
943
|
});
|
|
943
944
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {describe, expect, it} from "bun:test";
|
|
2
2
|
import {invalidAskFixtures, validAskFixtures} from "../tests/askFixtures";
|
|
3
|
+
import type {AskErrorCode} from "./errors";
|
|
3
4
|
import {type AskKind, type AskSurface, isCompactAskKind} from "./schema";
|
|
4
5
|
import {validateAskInput} from "./validateInput";
|
|
5
6
|
|
|
@@ -8,7 +9,7 @@ const NO_OPTION = {id: "no", label: "No"};
|
|
|
8
9
|
const TWO_OPTIONS = [{id: "yes", label: "Yes"}, NO_OPTION];
|
|
9
10
|
|
|
10
11
|
/** What the compact surface returns for each valid fixture, read off the compact rules by hand. */
|
|
11
|
-
const COMPACT_ERRORS_BY_FIXTURE: Record<string, {code:
|
|
12
|
+
const COMPACT_ERRORS_BY_FIXTURE: Record<string, {code: AskErrorCode; path: string}[]> = {
|
|
12
13
|
"choice-emoji-label-cut": [{code: "TOO_LONG", path: "options[0].label"}],
|
|
13
14
|
"choice-labels-collide-after-cut": [
|
|
14
15
|
{code: "TOO_LONG", path: "options[0].label"},
|
|
@@ -5,7 +5,7 @@ import {join} from "node:path";
|
|
|
5
5
|
import {BLOCK_ERROR_CODES, BLOCK_WARNING_CODES} from "./errors";
|
|
6
6
|
|
|
7
7
|
const reference = readFileSync(
|
|
8
|
-
join(
|
|
8
|
+
join(__dirname, "..", "..", "docs", "reference", "blocks.md"),
|
|
9
9
|
"utf8"
|
|
10
10
|
);
|
|
11
11
|
|
package/src/cli.test.ts
CHANGED
|
@@ -3,22 +3,22 @@ import {join} from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import {launchBlocksCli, runBlocksCli} from "./cli";
|
|
5
5
|
|
|
6
|
-
const validFixture = join(
|
|
7
|
-
const invalidFixture = join(
|
|
6
|
+
const validFixture = join(__dirname, "fixtures", "valid", "prose.yaml");
|
|
7
|
+
const invalidFixture = join(__dirname, "fixtures", "invalid", "unknown-key.yaml");
|
|
8
8
|
|
|
9
9
|
describe("terreno-blocks validate", () => {
|
|
10
10
|
it("exits 0 for a valid file and 1 for an invalid file", async () => {
|
|
11
|
-
const valid = Bun.spawn(["bun", join(
|
|
11
|
+
const valid = Bun.spawn(["bun", join(__dirname, "cli.ts"), "validate", validFixture], {
|
|
12
12
|
stderr: "pipe",
|
|
13
13
|
stdout: "pipe",
|
|
14
14
|
});
|
|
15
15
|
expect(await valid.exited).toBe(0);
|
|
16
16
|
expect(await new Response(valid.stdout).text()).toBe("");
|
|
17
17
|
|
|
18
|
-
const invalid = Bun.spawn(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
18
|
+
const invalid = Bun.spawn(["bun", join(__dirname, "cli.ts"), "validate", invalidFixture], {
|
|
19
|
+
stderr: "pipe",
|
|
20
|
+
stdout: "pipe",
|
|
21
|
+
});
|
|
22
22
|
expect(await invalid.exited).toBe(1);
|
|
23
23
|
const report = await new Response(invalid.stdout).text();
|
|
24
24
|
expect(report).toContain("blocks[0].color UNKNOWN_KEY ");
|
package/src/golden.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {blockPlainText} from "./plainText";
|
|
|
7
7
|
import type {Block, BlocksDocument} from "./schema";
|
|
8
8
|
import {validateBlocks} from "./validate";
|
|
9
9
|
|
|
10
|
-
const goldenPath = join(
|
|
10
|
+
const goldenPath = join(__dirname, "fixtures", "golden", "sunday-roast.yaml");
|
|
11
11
|
|
|
12
12
|
// The roast reply needs these options: https images and both host callbacks.
|
|
13
13
|
const goldenOptions = {
|
package/src/jsonSchema.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {blocksJsonSchema} from "./jsonSchema";
|
|
|
7
7
|
import {parseBlocks} from "./parse";
|
|
8
8
|
import {blocksSchema} from "./schema";
|
|
9
9
|
|
|
10
|
-
const fixturesDir = join(
|
|
10
|
+
const fixturesDir = join(__dirname, "fixtures");
|
|
11
11
|
|
|
12
12
|
describe("blocksJsonSchema", () => {
|
|
13
13
|
it("accepts and rejects the same parsed fixtures as the Zod schema", () => {
|
package/src/parse.test.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import {describe, expect, it} from "bun:test";
|
|
2
2
|
import {readdirSync, readFileSync} from "node:fs";
|
|
3
3
|
import {join} from "node:path";
|
|
4
|
-
|
|
5
|
-
import {parseBlocks} from "./parse";
|
|
4
|
+
import type {BlockError, BlockErrorCode} from "./errors";
|
|
5
|
+
import {type ParseBlocksResult, parseBlocks} from "./parse";
|
|
6
6
|
import {wrapAsTextDocument} from "./schema";
|
|
7
|
-
import {validateBlocks} from "./validate";
|
|
7
|
+
import {type ValidateBlocksResult, validateBlocks} from "./validate";
|
|
8
|
+
|
|
9
|
+
/** The errors of a failed parse or validation, or none when it succeeded. */
|
|
10
|
+
const errorsOf = (result: ParseBlocksResult | ValidateBlocksResult): BlockError[] =>
|
|
11
|
+
result.ok ? [] : result.errors;
|
|
8
12
|
|
|
9
|
-
const fixturesDir = join(
|
|
13
|
+
const fixturesDir = join(__dirname, "fixtures");
|
|
10
14
|
|
|
11
|
-
const expectedInvalid: Record<string, {code:
|
|
15
|
+
const expectedInvalid: Record<string, {code: BlockErrorCode; path: string}[]> = {
|
|
12
16
|
// An empty visibleText string trips both the min(1) and the blank-text checks.
|
|
13
17
|
"card-eyebrow-empty.yaml": [
|
|
14
18
|
{code: "TOO_SHORT", path: "blocks[0].eyebrow"},
|
|
@@ -159,11 +163,7 @@ describe("block fixtures", () => {
|
|
|
159
163
|
expect(names.sort()).toEqual(Object.keys(expectedInvalid).sort());
|
|
160
164
|
for (const name of names) {
|
|
161
165
|
const parsed = parseBlocks(readFileSync(join(dir, name), "utf8"));
|
|
162
|
-
const errors = parsed.ok
|
|
163
|
-
? validateBlocks(parsed.value).ok
|
|
164
|
-
? []
|
|
165
|
-
: validateBlocks(parsed.value).errors
|
|
166
|
-
: parsed.errors;
|
|
166
|
+
const errors = parsed.ok ? errorsOf(validateBlocks(parsed.value)) : parsed.errors;
|
|
167
167
|
expect(errors.map((item) => ({code: item.code, path: item.path}))).toEqual(
|
|
168
168
|
expectedInvalid[name]
|
|
169
169
|
);
|
|
@@ -208,13 +208,13 @@ describe("validateBlocks limits", () => {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
it("rejects empty input, a second document, and a mapping without v", () => {
|
|
211
|
-
expect(parseBlocks("")
|
|
211
|
+
expect(errorsOf(parseBlocks(""))).toEqual([
|
|
212
212
|
expect.objectContaining({code: "NOT_A_DOCUMENT", path: ""}),
|
|
213
213
|
]);
|
|
214
|
-
expect(parseBlocks("v: 1\n---\nv: 1\n")
|
|
214
|
+
expect(errorsOf(parseBlocks("v: 1\n---\nv: 1\n"))).toEqual([
|
|
215
215
|
expect.objectContaining({code: "NOT_A_DOCUMENT", path: ""}),
|
|
216
216
|
]);
|
|
217
|
-
expect(parseBlocks("blocks:\n - type: divider\n")
|
|
217
|
+
expect(errorsOf(parseBlocks("blocks:\n - type: divider\n"))).toEqual([
|
|
218
218
|
expect.objectContaining({code: "NOT_A_DOCUMENT", path: ""}),
|
|
219
219
|
]);
|
|
220
220
|
});
|
package/src/parsePartial.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {join} from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import {parseBlocksPartial} from "./parsePartial";
|
|
6
6
|
|
|
7
|
-
const partialDir = join(
|
|
7
|
+
const partialDir = join(__dirname, "fixtures", "partial");
|
|
8
8
|
|
|
9
9
|
describe("parseBlocksPartial", () => {
|
|
10
10
|
it("returns every block of a finished document and pending false", () => {
|
package/src/tests/askFixtures.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {readdirSync, readFileSync} from "node:fs";
|
|
2
2
|
import {join} from "node:path";
|
|
3
|
+
import type {AskErrorCode} from "../asks/errors";
|
|
3
4
|
import type {Ask, AskKind} from "../asks/schema";
|
|
4
5
|
import type {SimpleCard} from "../asks/simpleCard";
|
|
5
6
|
|
|
6
|
-
const FIXTURES_DIR = join(
|
|
7
|
+
const FIXTURES_DIR = join(__dirname, "..", "asks", "fixtures");
|
|
7
8
|
|
|
8
9
|
export type ValidAskFixture = Ask & {
|
|
9
10
|
name: string;
|
|
@@ -11,7 +12,7 @@ export type ValidAskFixture = Ask & {
|
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export interface InvalidAskFixture {
|
|
14
|
-
errors: {code:
|
|
15
|
+
errors: {code: AskErrorCode; path: string}[];
|
|
15
16
|
input: unknown;
|
|
16
17
|
kind: AskKind;
|
|
17
18
|
name: string;
|
|
@@ -38,3 +39,10 @@ export const validAskFixturesOf = <Kind extends AskKind>(
|
|
|
38
39
|
|
|
39
40
|
export const invalidAskFixtures = (): InvalidAskFixture[] =>
|
|
40
41
|
readFixtures<InvalidAskFixture>("invalid");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The ask a fixture describes. The fixture spreads `Ask`, but TypeScript cannot keep `input` and
|
|
45
|
+
* `kind` correlated once they are read separately, so this restates the pairing.
|
|
46
|
+
*/
|
|
47
|
+
export const fixtureAsk = (fixture: ValidAskFixture): Ask =>
|
|
48
|
+
({input: fixture.input, kind: fixture.kind}) as Ask;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"composite": false,
|
|
6
|
+
"incremental": false,
|
|
7
|
+
"declaration": false,
|
|
8
|
+
"declarationMap": false,
|
|
9
|
+
"types": [
|
|
10
|
+
"node",
|
|
11
|
+
"bun"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"exclude": [
|
|
15
|
+
"node_modules",
|
|
16
|
+
"dist"
|
|
17
|
+
]
|
|
18
|
+
}
|