mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
A presentation draws tool calls and colours; it executes nothing and validates nothing. Both cost it typebox anyway, which is 17 MB of module graph: 682 ESM modules whose narrowest entry re-exports ~300 of them, so there is no cherry-picking and both doors had to close before any process stopped paying. ToolExecutionComponent no longer looks up createAllToolDefinitions by tool name; callers pass what to render, and its parameter type now says so. Each built-in tool's presentation moves to core/tools/renderers/<tool>.ts, leaving the schema and execute path behind; bash and powershell share createShellRenderers(prompt). Tool definitions keep their exact public shape and spread the renderers in. withBuiltInRenderers() preserves the old fallback, where an extension override without renderers still draws like the built-in - that behaviour used to live inside the component, and two tests caught its absence. theme.ts keeps palettes; the typebox schema and its error formatting move to theme-json.ts, installed through setThemeJsonValidator. Built-in themes were never validated, so only processes that load user-authored themes need it. pi installs it during startup, before the first theme loads, and the invalid-theme message is unchanged. Measured with mini, running built output: the presentation drops from 87 MB and 99 files to 72 MB and 88 files, with typebox absent from its graph, while still rendering tool output through the real built-in renderers. Also: packages/server's vitest aliases were prefix matches, so a subpath import resolved onto index.ts/utils/uuid and failed five suites; they are exact now with explicit subpath rules. And mini-test.sh spawns mini the way pi-test.sh spawns pi, with --fresh/--stop for the detached session server.
584 lines
18 KiB
TypeScript
584 lines
18 KiB
TypeScript
import { join, resolve } from "node:path";
|
|
import { Text, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
import { Type } from "typebox";
|
|
import { beforeAll, describe, expect, test } from "vitest";
|
|
import { getReadmePath } from "../src/config.ts";
|
|
import type { ToolDefinition } from "../src/core/extensions/types.ts";
|
|
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.ts";
|
|
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.ts";
|
|
import { withBuiltInRenderers } from "../src/core/tools/renderers/index.ts";
|
|
import { createWriteToolDefinition } from "../src/core/tools/write.ts";
|
|
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts";
|
|
import { initTheme, theme } from "../src/modes/interactive/theme/theme.ts";
|
|
import { stripAnsi } from "../src/utils/ansi.ts";
|
|
|
|
function createBaseToolDefinition(name = "custom_tool"): ToolDefinition {
|
|
return {
|
|
name,
|
|
label: name,
|
|
description: "custom tool",
|
|
parameters: Type.Any(),
|
|
execute: async () => ({
|
|
content: [{ type: "text", text: "ok" }],
|
|
details: {},
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createFakeTui(): TUI {
|
|
return {
|
|
requestRender: () => {},
|
|
} as unknown as TUI;
|
|
}
|
|
|
|
describe("ToolExecutionComponent parity", () => {
|
|
beforeAll(() => {
|
|
initTheme("dark");
|
|
});
|
|
|
|
test("stacks custom call and result renderers like the old implementation", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
renderCall: () => new Text("custom call", 0, 0),
|
|
renderResult: () => new Text("custom result", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-1",
|
|
{},
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
|
|
|
|
component.updateResult(
|
|
{
|
|
content: [{ type: "text", text: "done" }],
|
|
details: {},
|
|
isError: false,
|
|
},
|
|
false,
|
|
);
|
|
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("custom call");
|
|
expect(rendered).toContain("custom result");
|
|
});
|
|
|
|
test("self-rendered empty tool rows take no layout space", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
renderShell: "self",
|
|
renderCall: () => new Text("", 0, 0),
|
|
renderResult: () => new Text("", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-empty-self-render",
|
|
{},
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
expect(component.render(120)).toEqual([]);
|
|
|
|
component.updateResult(
|
|
{
|
|
content: [],
|
|
details: {},
|
|
isError: false,
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(component.render(120)).toEqual([]);
|
|
});
|
|
|
|
test("uses built-in rendering for built-in overrides without custom renderers", () => {
|
|
const overrideDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition("edit"),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"edit",
|
|
"tool-2",
|
|
{ path: "README.md", oldText: "before", newText: "after" },
|
|
{},
|
|
withBuiltInRenderers("edit", overrideDefinition),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false });
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("edit");
|
|
expect(rendered).toContain("README.md");
|
|
expect(rendered).not.toContain(":1");
|
|
});
|
|
|
|
test("preserves legacy file_path rendering compatibility for built-in tools", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-3",
|
|
{ file_path: "README.md" },
|
|
{},
|
|
undefined,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("read");
|
|
expect(rendered).toContain("README.md");
|
|
});
|
|
|
|
test("bash execute emits an initial empty partial update before output arrives", async () => {
|
|
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
|
|
const operations: BashOperations = {
|
|
exec: async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return { exitCode: 0 };
|
|
},
|
|
};
|
|
const tool = createBashToolDefinition(process.cwd(), { operations, exposeSessionEnvironment: false });
|
|
const promise = tool.execute(
|
|
"tool-bash-1",
|
|
{ command: "sleep 10" },
|
|
undefined,
|
|
(update) => updates.push(update as { content: Array<{ type: string; text?: string }>; details?: unknown }),
|
|
{} as never,
|
|
);
|
|
expect(updates).toEqual([{ content: [], details: undefined }]);
|
|
await promise;
|
|
});
|
|
|
|
test("bash renderer does not duplicate final full output truncation details", async () => {
|
|
const operations: BashOperations = {
|
|
exec: async (_command, _cwd, { onData }) => {
|
|
for (let i = 1; i <= 4000; i++) {
|
|
onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`));
|
|
}
|
|
return { exitCode: 0 };
|
|
},
|
|
};
|
|
const tool = createBashToolDefinition(process.cwd(), { operations, exposeSessionEnvironment: false });
|
|
const result = await tool.execute(
|
|
"tool-bash-1b",
|
|
{ command: "generate output" },
|
|
undefined,
|
|
undefined,
|
|
{} as never,
|
|
);
|
|
const component = new ToolExecutionComponent(
|
|
"bash",
|
|
"tool-bash-1b",
|
|
{ command: "generate output" },
|
|
{},
|
|
tool,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.setExpanded(true);
|
|
component.updateResult({ ...result, isError: false }, false);
|
|
|
|
const rendered = stripAnsi(component.render(200).join("\n"));
|
|
expect(rendered.match(/Full output:/g)?.length ?? 0).toBe(1);
|
|
expect(rendered).toMatch(/line-4000[^\n]*\n[^\S\n]*\n \[Full output:/);
|
|
expect(rendered).not.toMatch(/line-4000[^\n]*\n[^\S\n]*\n[^\S\n]*\n \[Full output:/);
|
|
expect(rendered).toContain("Truncated: showing 2000 of 4000 lines");
|
|
expect(rendered).not.toContain("[Showing lines 2001-4000 of 4000. Full output:");
|
|
});
|
|
|
|
test("does not duplicate built-in headers when passed the active built-in definition", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4",
|
|
{ path: "README.md" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered.match(/\bread\b/g)?.length ?? 0).toBe(1);
|
|
});
|
|
|
|
test("inherits missing built-in result renderer slot from the built-in tool", () => {
|
|
const overrideDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition("read"),
|
|
renderCall: () => new Text("override call", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4b",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
withBuiltInRenderers("read", overrideDefinition),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
component.setExpanded(true);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("override call");
|
|
expect(rendered).toContain("hello");
|
|
});
|
|
|
|
test("inherits missing built-in call renderer slot from the built-in tool", () => {
|
|
const overrideDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition("read"),
|
|
renderResult: () => new Text("override result", 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4c",
|
|
{ path: "README.md" },
|
|
{},
|
|
withBuiltInRenderers("read", overrideDefinition),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("read");
|
|
expect(rendered).toContain("README.md");
|
|
expect(rendered).toContain("override result");
|
|
});
|
|
|
|
test("uses custom renderers for built-in overrides that reuse built-in definition parameters", () => {
|
|
const builtInDefinition = createReadToolDefinition(process.cwd());
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4d",
|
|
{ path: "README.md" },
|
|
{},
|
|
{
|
|
...builtInDefinition,
|
|
renderCall: () => new Text("override call", 0, 0),
|
|
renderResult: () => new Text("override result", 0, 0),
|
|
},
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("override call");
|
|
expect(rendered).toContain("override result");
|
|
expect(rendered).not.toContain("read README.md");
|
|
});
|
|
|
|
test("uses custom renderers for built-in overrides that reuse wrapped built-in tool parameters", () => {
|
|
const builtInTool = createReadTool(process.cwd());
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-4e",
|
|
{ path: "README.md" },
|
|
{},
|
|
{
|
|
...createBaseToolDefinition("read"),
|
|
parameters: builtInTool.parameters,
|
|
renderCall: () => new Text("wrapped override call", 0, 0),
|
|
renderResult: () => new Text("wrapped override result", 0, 0),
|
|
},
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("wrapped override call");
|
|
expect(rendered).toContain("wrapped override result");
|
|
});
|
|
|
|
test("shares renderer state across custom call and result slots", () => {
|
|
type RenderState = { token?: string };
|
|
const toolDefinition: ToolDefinition<any, unknown, RenderState> = {
|
|
...createBaseToolDefinition(),
|
|
renderCall: (_args, _theme, context) => {
|
|
context.state.token ??= "shared-token";
|
|
return new Text(`custom call ${context.state.token}`, 0, 0);
|
|
},
|
|
renderResult: (_result, _options, _theme, context) => {
|
|
return new Text(`custom result ${context.state.token}`, 0, 0);
|
|
},
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-5",
|
|
{},
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("custom call shared-token");
|
|
expect(rendered).toContain("custom result shared-token");
|
|
});
|
|
|
|
test("exposes args in render result context", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
renderCall: () => new Text("call", 0, 0),
|
|
renderResult: (_result, _options, _theme, context) =>
|
|
new Text(`arg:${String((context.args as { foo: string }).foo)}`, 0, 0),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-5b",
|
|
{ foo: "bar" },
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("arg:bar");
|
|
});
|
|
|
|
test("collapses fallback results until expanded", () => {
|
|
const toolDefinition: ToolDefinition = {
|
|
...createBaseToolDefinition(),
|
|
};
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"custom_tool",
|
|
"tool-6",
|
|
{ foo: "bar" },
|
|
{},
|
|
toolDefinition,
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const output = Array.from({ length: 15 }, (_, index) => `line-${index + 1}`).join("\n");
|
|
component.updateResult({ content: [{ type: "text", text: output }], details: {}, isError: false }, false);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain("custom_tool");
|
|
expect(collapsed).toContain("line-10");
|
|
expect(collapsed).not.toContain("line-11");
|
|
expect(collapsed).toContain("5 more lines");
|
|
expect(collapsed).toContain("to expand");
|
|
|
|
component.setExpanded(true);
|
|
const expanded = stripAnsi(component.render(120).join("\n"));
|
|
expect(expanded).toContain("line-15");
|
|
expect(expanded).not.toContain("more lines");
|
|
});
|
|
|
|
test("trims trailing blank display lines from write previews", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"write",
|
|
"tool-7",
|
|
{ path: "README.md", content: "one\ntwo\n" },
|
|
{},
|
|
createWriteToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("one");
|
|
expect(rendered).toContain("two");
|
|
expect(rendered).not.toContain("two\n\n");
|
|
});
|
|
|
|
test("trims trailing blank display lines from read results", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-8",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
component.setExpanded(true);
|
|
const rendered = stripAnsi(component.render(120).join("\n"));
|
|
expect(rendered).toContain("one");
|
|
expect(rendered).toContain("two");
|
|
expect(rendered).not.toContain("two\n\n");
|
|
});
|
|
|
|
test("does not syntax-highlight read errors based on the requested file path", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-read-error-highlighting",
|
|
{ path: "config.exs", offset: 120, limit: 130 },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
const error = "Offset 120 is beyond end of file (96 lines total)";
|
|
component.updateResult({ content: [{ type: "text", text: error }], details: undefined, isError: true }, false);
|
|
|
|
const rendered = component.render(120).join("\n");
|
|
expect(stripAnsi(rendered)).toContain(error);
|
|
expect(rendered).toContain(theme.fg("toolOutput", error));
|
|
});
|
|
|
|
test("expands a collapsed tool result when clicked", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-click-expand",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
const width = 120;
|
|
const lines = component.render(width);
|
|
const resultRow = lines.findIndex((line) => stripAnsi(line).includes("notes.txt"));
|
|
expect(resultRow).toBeGreaterThanOrEqual(0);
|
|
const event: TuiMouseEvent = {
|
|
type: "click",
|
|
button: "left",
|
|
x: 2,
|
|
y: resultRow,
|
|
screenX: 2,
|
|
screenY: resultRow,
|
|
width,
|
|
height: lines.length,
|
|
shift: false,
|
|
alt: false,
|
|
ctrl: false,
|
|
clickCount: 1,
|
|
};
|
|
expect(component.handleMouse(event)?.handled).toBe(true);
|
|
expect(stripAnsi(component.render(width).join("\n"))).toContain("hidden content");
|
|
});
|
|
|
|
test("collapses ordinary read results until expanded", () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
"tool-ordinary-read-collapsed",
|
|
{ path: "notes.txt" },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain("read");
|
|
expect(collapsed).toContain("notes.txt");
|
|
expect(collapsed).not.toContain("hidden content");
|
|
|
|
component.setExpanded(true);
|
|
const expanded = stripAnsi(component.render(120).join("\n"));
|
|
expect(expanded).toContain("hidden content");
|
|
});
|
|
|
|
for (const scenario of [
|
|
{
|
|
title: "SKILL.md",
|
|
path: join(process.cwd(), "attio", "SKILL.md"),
|
|
content: "---\nname: attio\ndescription: CRM helper\n---\n\n# Hidden skill instructions",
|
|
compact: "[skill] attio",
|
|
hidden: "Hidden skill instructions",
|
|
absent: "read skill attio",
|
|
},
|
|
{
|
|
title: "AGENTS.md",
|
|
path: join(process.cwd(), ".pi", "AGENTS.md"),
|
|
content: "Hidden resource instructions",
|
|
compact: "read resource .pi/AGENTS.md",
|
|
hidden: "Hidden resource instructions",
|
|
absent: undefined,
|
|
},
|
|
{
|
|
title: "AGENTS.override.md",
|
|
path: join(process.cwd(), ".pi", "AGENTS.override.md"),
|
|
content: "Hidden override instructions",
|
|
compact: "read resource .pi/AGENTS.override.md",
|
|
hidden: "Hidden override instructions",
|
|
absent: undefined,
|
|
},
|
|
{
|
|
title: "outside AGENTS.md",
|
|
path: resolve(process.cwd(), "..", "AGENTS.md"),
|
|
content: "Hidden outside resource instructions",
|
|
compact: `read resource ${resolve(process.cwd(), "..", "AGENTS.md").replace(/\\/g, "/")}`,
|
|
hidden: "Hidden outside resource instructions",
|
|
absent: undefined,
|
|
},
|
|
{
|
|
title: "Pi documentation",
|
|
path: getReadmePath(),
|
|
content: "Hidden docs content",
|
|
compact: "read docs README.md",
|
|
hidden: "Hidden docs content",
|
|
absent: undefined,
|
|
},
|
|
] as const) {
|
|
test(`renders ${scenario.title} read results compactly until expanded`, () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
`tool-compact-${scenario.title}`,
|
|
{ path: scenario.path },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: scenario.content }], details: undefined, isError: false },
|
|
false,
|
|
);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain(scenario.compact);
|
|
expect(collapsed).not.toContain(scenario.hidden);
|
|
if (scenario.absent) {
|
|
expect(collapsed).not.toContain(scenario.absent);
|
|
}
|
|
|
|
component.setExpanded(true);
|
|
const expanded = stripAnsi(component.render(120).join("\n"));
|
|
expect(expanded).toContain(scenario.hidden);
|
|
});
|
|
}
|
|
|
|
for (const scenario of [
|
|
{ title: "SKILL.md", path: join(process.cwd(), "attio", "SKILL.md"), compact: "[skill] attio:120-329" },
|
|
{ title: "Pi documentation", path: getReadmePath(), compact: "read docs README.md:120-329" },
|
|
] as const) {
|
|
test(`shows the read line range in compact ${scenario.title} reads before the expand hint`, () => {
|
|
const component = new ToolExecutionComponent(
|
|
"read",
|
|
`tool-compact-range-${scenario.title}`,
|
|
{ path: scenario.path, offset: 120, limit: 210 },
|
|
{},
|
|
createReadToolDefinition(process.cwd()),
|
|
createFakeTui(),
|
|
process.cwd(),
|
|
);
|
|
|
|
const collapsed = stripAnsi(component.render(120).join("\n"));
|
|
expect(collapsed).toContain(scenario.compact);
|
|
expect(collapsed.indexOf(":120-329")).toBeLessThan(collapsed.indexOf("to expand"));
|
|
});
|
|
}
|
|
});
|