mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 23:46:16 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
261892cfab | ||
|
|
97303c39dd |
@@ -231,92 +231,6 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
...Array.from({ length: 24 }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_scroll_${index}`,
|
||||
})),
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
|
||||
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(settings).toHaveText("Settings")
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(settings).toBeInViewport({ ratio: 1 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(settings).toHaveCSS("margin-top", "8px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinned = await settings.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await testInfo.attach(`vertical-tabs-settings-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
|
||||
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
|
||||
await expect(tabB).toBeInViewport({ ratio: 1 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tab = await tabB.boundingBox()
|
||||
const viewport = await scroll.boundingBox()
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
}
|
||||
|
||||
await settings.click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await expect(settings).toHaveAttribute("aria-pressed", "true")
|
||||
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeHidden()
|
||||
await settings.focus()
|
||||
await settings.press("Enter")
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("appearance experimental settings control vertical tab details", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
style={{
|
||||
width: `${state.tabsWidth}px`,
|
||||
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
|
||||
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs-scroll"] {
|
||||
min-height: 0;
|
||||
/* Leave enough space to scroll the final tab clear of the fade above Settings. */
|
||||
padding-block-end: 16px;
|
||||
scroll-padding-block-end: 16px;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-trigger"][aria-expanded="true"] {
|
||||
background:
|
||||
linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
|
||||
|
||||
@@ -670,19 +670,10 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-settings"
|
||||
data-state={layout.route().type === "settings" ? "pressed" : undefined}
|
||||
class="mt-2 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]"
|
||||
onClick={openSettings}
|
||||
aria-label={language.t("sidebar.settings")}
|
||||
aria-pressed={layout.route().type === "settings"}
|
||||
<div
|
||||
data-slot="vertical-tabs-footer"
|
||||
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<Icon name="settings-gear" />
|
||||
{language.t("sidebar.settings")}
|
||||
</button>
|
||||
<div data-slot="vertical-tabs-footer" class="flex w-full shrink-0 items-center gap-1.5">
|
||||
<TitlebarRightMount />
|
||||
<Show when={!macVerticalTabs() && !windows()}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
|
||||
@@ -149,6 +149,7 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
const char = input[index]
|
||||
if (!wordStarted) wordStart = index
|
||||
if (!quote && !wordStarted) {
|
||||
if (char === " " || char === "\t") continue
|
||||
const structure = structures.at(-1)
|
||||
const token = /^[A-Za-z_][A-Za-z0-9_]*(?=[ \t\n;()<>]|$)/.exec(input.slice(index))?.[0]
|
||||
if (structure?.kind === "case" && structure.phase === "header" && token === "in") {
|
||||
@@ -166,13 +167,24 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (structure?.kind === "for" && structure.phase === "header" && char === "(" && input[index + 1] !== "(") {
|
||||
const values = bashExpansion(input, index, depth, "array")
|
||||
if (!values) return { kind: "opaque", reason: "compound-command" }
|
||||
finishCommand()
|
||||
const failure = addSubstitutions(values)
|
||||
if (failure) return failure
|
||||
commands.push(...nestedCommands.splice(0))
|
||||
// Zsh permits a sublist or brace group directly after the value list, without do/done.
|
||||
structure.phase = "do"
|
||||
if (!/^(?:[ \t\n;]|\\\n|#[^\n]*(?:\n|$))*do(?=[ \t\n;]|$)/.test(input.slice(values.end + 1))) structures.pop()
|
||||
index = values.end
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (!words.length && !hasRedirect && !compoundEnd) {
|
||||
const definition =
|
||||
/^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t\n]*(?=[{(])/.exec(
|
||||
input.slice(index),
|
||||
)
|
||||
const definition = bashFunctionHead(input, index)
|
||||
if (definition && !header()) {
|
||||
index += definition[0].length - 1
|
||||
index += definition.length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
@@ -536,6 +548,14 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
|
||||
type BashExpansion = { source: string; end: number; substitutions?: string[] }
|
||||
|
||||
function bashFunctionHead(input: string, start: number) {
|
||||
// Share recognition with delimiter scanning so case patterns in function bodies do not close the outer group.
|
||||
// Names need not be variable identifiers. Zsh permits anonymous functions, including in an if condition.
|
||||
return /^(?!if(?:[ \t]|\\\n)*\()(?:function[ \t]+(?:\\\n[ \t]*)*[A-Za-z_][A-Za-z0-9_.:-]*(?:(?:[ \t]|\\\n)*\([ \t]*\))?|(?:[A-Za-z_][A-Za-z0-9_.:-]*(?:[ \t]|\\\n)*)?\([ \t]*\))(?:[ \t\n]|\\\n|#[^\n]*(?:\n|$))*(?=[{(]|\[\[(?=[ \t\n])|(?:if|while|until|for|select|case)[ \t\n])/.exec(
|
||||
input.slice(start),
|
||||
)?.[0]
|
||||
}
|
||||
|
||||
function bashDelimited(input: string, start: number, depth: number): BashExpansion | undefined {
|
||||
if (depth >= MAX_SUBSTITUTION_DEPTH) return
|
||||
const close = input[start] === "{" ? "}" : ")"
|
||||
@@ -544,6 +564,7 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
let commandStart = true
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (char === " " || char === "\t") continue
|
||||
if (char === "\\") {
|
||||
if (input[index + 1] !== "\n") commandStart = false
|
||||
index++
|
||||
@@ -566,6 +587,11 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
continue
|
||||
}
|
||||
const boundary = index === start + 1 || /[ \t\n;|&(){}]/.test(input[index - 1])
|
||||
const definition = commandStart && boundary ? bashFunctionHead(input, index) : undefined
|
||||
if (definition) {
|
||||
index += definition.length - 1
|
||||
continue
|
||||
}
|
||||
if (char === "#" && boundary) {
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline < 0) return
|
||||
@@ -725,7 +751,10 @@ function bashExpansion(
|
||||
index = nested.end
|
||||
continue
|
||||
}
|
||||
if (kind === "array" && "<>=".includes(char) && input[index + 1] === "(") {
|
||||
if (
|
||||
((kind === "array" && "<>=".includes(char)) || (kind === "test" && "<>".includes(char))) &&
|
||||
input[index + 1] === "("
|
||||
) {
|
||||
const nested = bashDelimited(input, index + 1, depth + 1)
|
||||
if (!nested) return
|
||||
substitutions.push(nested.source)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if true; then ${source}; fi`,
|
||||
(source: string) => `outer() { ${source}; }; outer`,
|
||||
]
|
||||
|
||||
const bodies = [
|
||||
"for value in one two; do scan_probe; done",
|
||||
"while true; do scan_probe; break; done",
|
||||
"until false; do scan_probe; break; done",
|
||||
"case value in value) scan_probe;; *) scan_other;; esac",
|
||||
]
|
||||
|
||||
describe("compound function acceptance", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
for (const head of ["probe()", "function probe", "function probe()", "probe-name()"])
|
||||
for (const body of bodies)
|
||||
for (const context of contexts) {
|
||||
const name = head.includes("probe-name") ? "probe-name" : "probe"
|
||||
const source = context(`${head} ${body}; ${name}`)
|
||||
test(`${shell}: ${source}`, async () => {
|
||||
// Braces preserve the function's behavior, but avoid Tree-sitter's recovery artifacts.
|
||||
const legacy = await Effect.runPromise(
|
||||
ShellParse.scan(context(`${head} { ${body}; }; ${name}`), shell, "/workspace"),
|
||||
)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test.each(bodies)("keeps compound function bodies inside command substitutions: %s", (body) => {
|
||||
const source = `printf '%s' "$( probe() ${body}; probe )"`
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands[0]?.resource).toBe(source)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
expect(result.commands.at(-1)?.words).toEqual(["probe"])
|
||||
})
|
||||
})
|
||||
|
||||
const values = [
|
||||
"one two",
|
||||
"'two words' one",
|
||||
"'cd' '/outside'",
|
||||
"'do' 'done'",
|
||||
"'(literal)' '$(scan_ignored)'",
|
||||
"one\\\ntwo",
|
||||
"$(printf one)",
|
||||
'"$(printf one)"',
|
||||
"<(printf one)",
|
||||
"",
|
||||
]
|
||||
const loops = values.flatMap((value) =>
|
||||
[
|
||||
`for value (${value}) scan_probe "$value"`,
|
||||
`for value (${value}) { scan_probe "$value"; }`,
|
||||
...(value
|
||||
? [
|
||||
`for value (${value}) do scan_probe "$value"; done`,
|
||||
`for value (${value}); do scan_probe "$value"; done`,
|
||||
`for value (${value})\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) # ignored\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) \\\ndo scan_probe "$value"; done`,
|
||||
]
|
||||
: []),
|
||||
].map((source) => ({ source, equivalent: `for value in ${value}; do scan_probe "$value"; done` })),
|
||||
)
|
||||
|
||||
describe("Zsh parenthesized loop acceptance", () => {
|
||||
for (const fixture of loops)
|
||||
for (const context of contexts) {
|
||||
const source = context(fixture.source)
|
||||
test(source, async () => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"for x (one two) for y (a b) scan_probe",
|
||||
"for x (one two) scan_probe && scan_other",
|
||||
"for x (one two) scan_probe | scan_other",
|
||||
"printf '%s' \"$(for x (one two) scan_probe)\"",
|
||||
"for x (one two) { for y (a b); do scan_probe; done; }",
|
||||
"for x (one two) [[ $(scan_probe) == ok ]]",
|
||||
"for x (one two) (( 1 + $(scan_probe) ))",
|
||||
])("retains commands in nested shorthand loops: %s", (source) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
if (source.includes("scan_other"))
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_other")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-shell compound syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
...bodies.map((body) => `probe() ${body}; probe`),
|
||||
...bodies.map((body) => `printf '%s' "$(probe() ${body}; probe)"`),
|
||||
...(shell === "zsh" ? loops.map((fixture) => fixture.source) : []),
|
||||
])(`${shell}: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, timeout: 2_000 },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toEqual(
|
||||
source.includes("value ()") ? "" : expect.stringContaining("executed\n"),
|
||||
)
|
||||
expect(ShellScan.scan(source).kind).toBe("scanned")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const conditions = ["[[ -n <(scan_probe) ]]", "[[ -n >(scan_probe) ]]"]
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if ${source}; then printf visible; fi`,
|
||||
(source: string) => `check() { ${source}; }; check`,
|
||||
(source: string) => `printf '%s' "$( ${source}; printf visible)"`,
|
||||
]
|
||||
|
||||
const functions = ["probe", "probe-name", "probe.name", "probe:name"].flatMap((name) =>
|
||||
[`${name}()`, `function ${name}`, `function ${name}()`].flatMap((head) =>
|
||||
[
|
||||
"{ scan_probe; }",
|
||||
"(scan_probe)",
|
||||
"if true; then scan_probe; fi",
|
||||
"[[ $(scan_probe) == ok ]]",
|
||||
"(( 1 + $(scan_probe) ))",
|
||||
].map((body) => `${head} ${body}; ${name}`),
|
||||
),
|
||||
)
|
||||
|
||||
describe("legacy-accepted shell syntax regressions", () => {
|
||||
test.each(["() { scan_probe; }", "probe() { scan_probe; }; probe"])(
|
||||
"preserves deeply indented function definitions: %s",
|
||||
async (source) => {
|
||||
const command = `( ${" ".repeat(32_000)}${source} )`
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, "zsh", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
test.each(conditions.flatMap((source) => contexts.map((context) => context(source))))(
|
||||
"retains conditional process substitutions and permission resources: %s",
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "bash", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
test.each(
|
||||
["probe()", "probe \\\n()", "function \\\nprobe()", "function probe \\\n()"].flatMap((head) =>
|
||||
[" \\\n", " \\\n # ignored ) }\n", "# ignored \\\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves line continuations at function boundaries: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(
|
||||
["probe()", "function probe", "function probe()"].flatMap((head) =>
|
||||
[" # ignored ) }\n", "\n# ignored ) }\n\n", " # first\n# second\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves comments between a function head and its body: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(functions)(
|
||||
`${shell} preserves function resources, saved prefixes, and directories: %s`,
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
"probe-name() { scan_probe; }; probe-name",
|
||||
"function probe.name { scan_probe; }; probe.name",
|
||||
"probe:name() if true; then scan_probe; fi; probe:name",
|
||||
"probe()# ignored ) }\n{ scan_probe; }; probe",
|
||||
"probe \\\n() \\\n{ scan_probe; }; probe",
|
||||
"function \\\nprobe() # ignored \\\n{ scan_probe; }; probe",
|
||||
...(shell === "bash" ? conditions : ["() { scan_probe; }"]),
|
||||
])(`${shell} really executes the extracted command: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toBe("executed\n")
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"() { scan_probe; }",
|
||||
"( () { scan_probe; } )",
|
||||
"{ () { scan_probe; }; }",
|
||||
"while() { scan_probe; break; }",
|
||||
"until() { scan_probe; break; }",
|
||||
])("preserves Zsh anonymous functions and parenthesized loop permissions: %s", async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
// Tree-sitter recovers these valid Zsh forms with synthetic commands or truncated outer resources.
|
||||
// Pin both results rather than treating recovery artifacts as executable shell syntax.
|
||||
test.each([
|
||||
{
|
||||
source: "if () { scan_probe; }; then printf visible; fi",
|
||||
legacy: ["scan_probe", "then printf visible", "fi"],
|
||||
portable: ["scan_probe", "printf visible"],
|
||||
},
|
||||
{
|
||||
source: "check() { () { scan_probe; }; }; check",
|
||||
legacy: ["scan_probe", "}", "check"],
|
||||
portable: ["scan_probe", "check"],
|
||||
},
|
||||
{
|
||||
source: "printf '%s' \"$( () { scan_probe; }; printf visible)\"",
|
||||
legacy: ["printf '%s'", "scan_probe", "printf visible"],
|
||||
portable: ["printf '%s' \"$( () { scan_probe; }; printf visible)\"", "scan_probe", "printf visible"],
|
||||
},
|
||||
])("accepts anonymous-function compositions despite legacy recovery artifacts: $source", async (fixture) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(fixture.source, "zsh", "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scanPortable(fixture.source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.map((command) => command.resource)).toEqual([...fixture.legacy])
|
||||
expect(portable.commands.map((command) => command.resource)).toEqual([...fixture.portable])
|
||||
})
|
||||
|
||||
test.each([
|
||||
"[[ -n '<(scan_ignored)' ]]",
|
||||
'[[ -n "<(scan_ignored)" ]]',
|
||||
"[[ -n '>(scan_ignored)' ]]",
|
||||
'[[ -n ">(scan_ignored)" ]]',
|
||||
"[[ -n $'<(scan_ignored)' ]]",
|
||||
])("does not turn quoted process-substitution text into commands: %s", (source) => {
|
||||
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,30 @@ import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
|
||||
|
||||
// These ordinary forms must stay accepted, not disappear behind the oracle's opaque-result filter.
|
||||
const supported = [
|
||||
"Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
|
||||
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
|
||||
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
|
||||
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
|
||||
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
|
||||
"Invoke-ProbeA | ForEach-Object { Invoke-ProbeB }",
|
||||
"Invoke-ProbeA @'\nliteral ; }\n'@; Invoke-ProbeB",
|
||||
'Invoke-ProbeA @"\n$(Invoke-ProbeB)\n"@; Invoke-ProbeC',
|
||||
"Invoke-ProbeA `\n argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA 2>&1; Invoke-ProbeB",
|
||||
"& 'Invoke-ProbeA' argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA --% literal; ignored\nInvoke-ProbeB",
|
||||
]
|
||||
|
||||
test.each(supported)("accepts supported PowerShell syntax without an opaque escape hatch: %s", (source) => {
|
||||
expect(ShellScan.scanPowerShell(source).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
const fixtures = [
|
||||
...supported,
|
||||
...[
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = (Invoke-ProbeA); Invoke-ProbeB",
|
||||
@@ -343,6 +366,10 @@ test.skipIf(!pwsh)(
|
||||
let executed = 0
|
||||
for (const result of results) {
|
||||
const scan = ShellScan.scanPowerShell(result.source)
|
||||
if (supported.includes(result.source)) {
|
||||
expect(result.errors, result.source).toEqual([])
|
||||
expect(scan.kind, result.source).toBe("scanned")
|
||||
}
|
||||
if (scan.kind === "opaque" || result.errors.length > 0) continue
|
||||
scanned++
|
||||
executed += result.executed.length
|
||||
|
||||
@@ -526,6 +526,150 @@ describe("ShellTool scanner permissions", () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool conditional process substitution", () => {
|
||||
const test = isWindows || !Bun.which("bash") ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${portable ? "native" : "legacy"}: a nested deny prevents the substitution from running`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(toolIdentity.agent, (agent) => {
|
||||
agent.permissions = [
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "printf *", effect: "deny" },
|
||||
]
|
||||
}),
|
||||
)
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
'[[ -n <(printf reached > marker) ]]; wait "$!"',
|
||||
marker,
|
||||
[],
|
||||
)
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
|
||||
for (const reply of ["reject", "once", "always"] as const) {
|
||||
test(`${portable ? "native" : "legacy"}: conditional substitutions respect ${reply}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["wait *"] })
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const command = '[[ -n <(printf reached > marker) ]]; wait "$!"'
|
||||
const result = yield* runPermissionCommand(registry, command, marker, [reply])
|
||||
expect(result.requests).toMatchObject([
|
||||
{ action: "shell", resources: ["printf reached > marker", 'wait "$!"'], save: ["printf *", "wait *"] },
|
||||
])
|
||||
if (reply === "reject") {
|
||||
expect(Exit.isFailure(result.exit)).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
return
|
||||
}
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "completed", metadata: { exit: 0 } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
|
||||
yield* Effect.promise(() => fs.unlink(marker))
|
||||
const repeat = yield* runPermissionCommand(
|
||||
registry,
|
||||
command,
|
||||
marker,
|
||||
reply === "always" ? [] : ["reject"],
|
||||
)
|
||||
expect(repeat.requests).toHaveLength(reply === "always" ? 0 : 1)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(reply === "always")
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool compound syntax approval compatibility", () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) printf %s "$value"',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) { printf %s "$value"; }',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value ($(printf a)) do printf %s "$value"; done',
|
||||
equivalent: 'for value in $(printf a); do printf %s "$value"; done',
|
||||
output: "a",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'probe() for value in a b; do printf %s "$value"; done; probe',
|
||||
equivalent: 'probe() { for value in a b; do printf %s "$value"; done; }; probe',
|
||||
output: "ab",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'printf %s "$(probe() case value in value) printf hello;; esac; probe)"',
|
||||
equivalent: 'printf %s "$(probe() { case value in value) printf hello;; esac; }; probe)"',
|
||||
output: "hello",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
]) {
|
||||
const test = isWindows || !Bun.which(fixture.shell) ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${fixture.shell} ${portable ? "native" : "legacy equivalent"}: ${fixture.command}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
portable ? fixture.command : fixture.equivalent,
|
||||
path.join(directory.active, "marker"),
|
||||
[],
|
||||
)
|
||||
expect(result.requests).toEqual([])
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: {
|
||||
status: "completed",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: fixture.output }, { type: "text" }],
|
||||
},
|
||||
})
|
||||
}),
|
||||
fixture.shell,
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool ordinary shell syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
backgroundColor={
|
||||
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseMove={() => setStore("selected", index())}
|
||||
>
|
||||
<text
|
||||
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
? theme.background.action.primary.selected
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseMove={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", index())
|
||||
navigate({ type: "session", sessionID: entry.sessionID })
|
||||
|
||||
@@ -84,7 +84,7 @@ export function TerminalsTab(props: { sessionID: string; visibleTerminalID?: str
|
||||
? theme.background.action.primary.selected
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setSelected(index())}
|
||||
onMouseMove={() => setSelected(index())}
|
||||
onMouseUp={() => {
|
||||
setSelected(index())
|
||||
select()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
@@ -9,8 +10,13 @@ import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { SessionTerminalsProvider, useSessionTerminals } from "../../../src/context/session-terminals"
|
||||
import { StorageProvider, useStorage } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -22,9 +28,22 @@ const sessions = {
|
||||
}
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
||||
const terminals = ["First terminal", "Second terminal"].map((title, index) => ({
|
||||
id: `pty-${index}`,
|
||||
title,
|
||||
command: "/bin/sh",
|
||||
args: [],
|
||||
cwd: directory,
|
||||
status: "running",
|
||||
pid: index + 1,
|
||||
sessionID: "parent",
|
||||
foregroundProcess: null,
|
||||
size: { cols: 100, rows: 20 },
|
||||
output: { head: 0, tail: 0 },
|
||||
}))
|
||||
|
||||
async function renderComposer(
|
||||
defaultTab: "subagents" | "shell",
|
||||
defaultTab: "subagents" | "shell" | "terminals",
|
||||
keybinds: Partial<TuiKeybind.Keybinds>,
|
||||
focusedTextarea = false,
|
||||
) {
|
||||
@@ -32,10 +51,14 @@ async function renderComposer(
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const temporary = await tmpdir()
|
||||
let closed = 0
|
||||
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/experimental/session/parent/terminal") return json({ data: terminals })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } })
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
@@ -61,6 +84,8 @@ async function renderComposer(
|
||||
|
||||
function Content() {
|
||||
const data = useData()
|
||||
const terminals = useSessionTerminals()
|
||||
storage = useStorage()
|
||||
route = useRoute()
|
||||
dispatch = Keymap.use().dispatch
|
||||
onMount(() => {
|
||||
@@ -69,6 +94,7 @@ async function renderComposer(
|
||||
data.session.sync("child-a"),
|
||||
data.session.sync("child-b"),
|
||||
data.shell.sync(),
|
||||
terminals.refresh("parent"),
|
||||
])
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
.then(() => ready.resolve(), ready.reject)
|
||||
@@ -76,7 +102,13 @@ async function renderComposer(
|
||||
return (
|
||||
<>
|
||||
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
|
||||
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
<Composer
|
||||
sessionID="parent"
|
||||
open={open()}
|
||||
defaultTab={defaultTab}
|
||||
visibleTerminalID="pty-0"
|
||||
onClose={() => closed++}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -92,23 +124,31 @@ async function renderComposer(
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={directory}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds, session: { terminal: false } })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Content />
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
<TestTuiContexts directory={directory} paths={{ state: temporary.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds, session: { terminal: true } })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<ToastProvider>
|
||||
<SessionTerminalsProvider>
|
||||
<Content />
|
||||
</SessionTerminalsProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 20, kittyKeyboard: true },
|
||||
@@ -122,9 +162,85 @@ async function renderComposer(
|
||||
route: () => route.data,
|
||||
dispatch: (command: string) => dispatch(command),
|
||||
closed: () => closed,
|
||||
setOpen,
|
||||
selected: () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.filter((span) => span.attributes & TextAttributes.BOLD)
|
||||
.map((span) => span.text.trim()),
|
||||
async dispose() {
|
||||
app.renderer.destroy()
|
||||
await storage.flush()
|
||||
await temporary[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ tab: "subagents", first: "Build: First", second: "Build: Second" },
|
||||
{ tab: "shell", first: "bun test", second: "bun dev" },
|
||||
{ tab: "terminals", first: "First terminal", second: "Second terminal" },
|
||||
] as const
|
||||
|
||||
test.each([...tabs])("opening $tab under a stationary pointer preserves selection", async ({ tab, first, second }) => {
|
||||
const composer = await renderComposer(tab, {})
|
||||
try {
|
||||
const row = composer.app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes(second))
|
||||
expect(row).toBeGreaterThan(0)
|
||||
expect(composer.selected()).toContain(first)
|
||||
|
||||
composer.setOpen(false)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.mockMouse.moveTo(10, row)
|
||||
composer.setOpen(true)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.renderOnce()
|
||||
|
||||
expect(composer.selected()).toContain(first)
|
||||
expect(composer.selected()).not.toContain(second)
|
||||
} finally {
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([...tabs])("moving within a $tab row selects it after opening", async ({ tab, first, second }) => {
|
||||
const composer = await renderComposer(tab, {})
|
||||
try {
|
||||
const row = composer.app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes(second))
|
||||
expect(row).toBeGreaterThan(0)
|
||||
|
||||
composer.setOpen(false)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.mockMouse.moveTo(10, row)
|
||||
composer.setOpen(true)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.mockMouse.moveTo(11, row)
|
||||
await composer.app.renderOnce()
|
||||
|
||||
expect(composer.selected()).toContain(second)
|
||||
expect(composer.selected()).not.toContain(first)
|
||||
|
||||
composer.app.mockInput.pressArrow("up")
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.selected()).toContain(first)
|
||||
|
||||
await composer.app.mockMouse.moveTo(12, row)
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.selected()).toContain(second)
|
||||
} finally {
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("disabled subagent bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("subagents", {
|
||||
"composer.subagent.up": "none",
|
||||
@@ -146,7 +262,7 @@ test("disabled subagent bindings have no component fallbacks", async () => {
|
||||
composer.dispatch("composer.subagent.select")
|
||||
expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" })
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -169,7 +285,7 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -183,7 +299,7 @@ test("configured composer bindings work with a focused textarea", async () => {
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -194,7 +310,7 @@ test("ctrl+c closes the active composer", async () => {
|
||||
composer.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await composer.app.waitFor(() => composer.closed() === 1)
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user