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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: CI

on:
pull_request:
branches:
- main
types:
- opened
- synchronize
- reopened
- ready_for_review
- converted_to_draft

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
name: Typecheck and tests
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: npm ci

- name: Typecheck
run: bun run typecheck

- name: Test source and npm package
run: bun run test

e2e:
name: E2E tests
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install tmux
run: |
sudo apt-get update
sudo apt-get install -y tmux

- name: Install dependencies
run: npm ci

- name: Test in OpenCode
run: bun run test:e2e

- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-diagnostics
path: test-results/e2e/
retention-days: 7
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules/
test-results/
dist/
.opencode/
docs/*
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode-vim",
"version": "0.0.25",
"version": "0.0.26",
"type": "module",
"repository": {
"type": "git",
Expand All @@ -15,6 +15,7 @@
"prepack": "bun run build",
"typecheck": "tsc --noEmit",
"test": "bun test --conditions=browser --preload @opentui/solid/preload",
"test:e2e": "bun test/e2e/run.ts",
"bench": "bun test/benchmark.ts"
},
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion src/modules/vim/vimee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
const actions = applyKeybind(resolved.definition, ctx, map)
result = { newCtx: vim, actions }
} else {
// Match LazyVim's uncounted j/k and arrow mappings in normal/visual mode.
// Use screen-row movement for uncounted j/k and arrows in normal/visual mode.
// Apply this at the input boundary so custom mapping sequences stay literal.
if (!event.ctrl && vim.phase === "idle" && vim.count === 0 && (commandKey === "j" || commandKey === "k" || commandKey === "ArrowDown" || commandKey === "ArrowUp")) {
vim = processKey("g").newCtx
Expand Down
11 changes: 7 additions & 4 deletions src/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export function createSessionMode(context: Context, config: VimConfig, clipboard
let yankTimer: ReturnType<typeof setTimeout> | undefined
let yankID: string | undefined

context.keymap.layer(() => ({
mode: SESSION_MODE,
bindings: ["agent.cycle", "agent.cycle.reverse"],
}))

function selected() {
return transcript?.get(selectedID)
}
Expand Down Expand Up @@ -194,7 +199,6 @@ function MessageReader(props: {
const theme = () => context.theme.surface("dialog")
const background = () => theme().background.raised.high
const state = createVimState("normal")
const [line, setLine] = createSignal(1)
let input!: TextareaRenderable
const adapter = createVimeeAdapter(state, props.config, () => {}, { readOnly: true, onYank: props.copy })
const editorContext: PromptContext = {
Expand Down Expand Up @@ -232,12 +236,10 @@ function MessageReader(props: {
const rows = Math.max(1, Math.floor(input.height / (half ? 2 : 1)))
for (let row = 0; row < rows; row++) adapter.handle({ ...event, ctrl: false } as KeyEvent, down ? "j" : "k", editorContext)
} else adapter.handle(event, key, editorContext)
setLine(input.logicalCursor.row + 1)
}
onMount(() => {
input.cursorOffset = Math.min(props.offset, displayWidth(input.plainText))
adapter.attach(editorContext)
setLine(input.logicalCursor.row + 1)
input.focus()
context.renderer.keyInput.prependListener("keypress", onKey)
})
Expand Down Expand Up @@ -268,7 +270,7 @@ function MessageReader(props: {
backgroundColor={background()} focusedBackgroundColor={background()} focusedTextColor={theme().text.base} />
</box>
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexDirection="row" flexWrap="wrap" columnGap={3}>
<text fg={theme().text.muted}>{modeLabel()} · {line()}/{input?.lineCount ?? 1}</text>
<text fg={theme().text.muted}>{modeLabel()}</text>
<text fg={theme().text.muted}>
{props.notice() || (state.mode() !== "normal" ? "y copy · Esc cancel" : `v select · V lines · ${props.config.sessionKey} prompt`)}
</text>
Expand All @@ -281,6 +283,7 @@ function readerKey(context: Context, event: KeyEvent, sessionKey: string, mode =
if (event.defaultPrevented || context.keymap.mode.current() !== mode) return
if (context.keymap.pending().length || event.super || event.meta) return
const key = keyNotation(event)
if (key === "<Tab>" && event.shift) return
if (key && (key === sessionKey || !event.ctrl || key === "<C-[>" || pageCommand(key) || (mode === "modal" && key === "<C-c>"))) return key
}

Expand Down
138 changes: 138 additions & 0 deletions test/e2e/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { cp, mkdir } from "node:fs/promises"
import { createServer } from "node:net"
import path from "node:path"
import { createTerminal, type Terminal } from "./terminal"

export type Fixture = { terminal: Terminal; sessionTitle: string }

type Options = {
opencode: { binary: string; version: string }
plugin: string
directory: string
artifacts: string
}

export async function runWithFixture(options: Options, run: (fixture: Fixture) => Promise<void>) {
const { opencode, plugin, directory, artifacts } = options
const workspace = path.join(directory, "workspace")
const password = crypto.randomUUID()
const sessionTitle = "E2E existing session"
const terminal = createTerminal(path.join(directory, "tmux.sock"), artifacts)
let server: Bun.Subprocess | undefined
let url = ""

function stop() {
terminal.stop()
if (server) {
try {
process.kill(-server.pid, "SIGKILL")
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error
}
}
}

function interrupt() { stop(); process.exit(130) }
function terminate() { stop(); process.exit(143) }
process.once("SIGINT", interrupt)
process.once("SIGTERM", terminate)

try {
await mkdir(artifacts, { recursive: true })
await mkdir(workspace, { recursive: true })
for (const home of ["server", "tui"]) {
await mkdir(path.join(directory, home, "config"), { recursive: true })
}
assert.equal(execFileSync(opencode.binary, ["--version"], { env: environment("server"), encoding: "utf8" }).trim(), `opencode v${opencode.version}`)
await Bun.write(path.join(directory, "tui/config/cli.json"), JSON.stringify({
plugins: [plugin],
tabs: { mode: "on" },
attention: { notifications: false, sound: false },
}))

const listener = createServer()
await new Promise<void>((resolve, reject) => {
listener.once("error", reject)
listener.listen(0, "127.0.0.1", resolve)
})
const address = listener.address()
assert(address && typeof address !== "string")
const port = address.port
await new Promise<void>((resolve, reject) => listener.close((error) => error ? reject(error) : resolve()))
url = `http://127.0.0.1:${port}`
server = Bun.spawn([opencode.binary, "serve", "--hostname", "127.0.0.1", "--port", String(port)], {
cwd: workspace,
env: environment("server"),
detached: true,
stdout: Bun.file(path.join(artifacts, "server.stdout.log")),
stderr: Bun.file(path.join(artifacts, "server.stderr.log")),
})
const deadline = Date.now() + 60_000
while (true) {
if (server.exitCode !== null) throw new Error(`OpenCode server exited: ${server.exitCode}`)
try {
await request("/api/session")
break
} catch (error) {
if (Date.now() >= deadline) throw error
await Bun.sleep(100)
}
}
const session = (await request("/api/session", { title: sessionTitle, location: { directory: workspace } })).data
const command = ["env", "-i"]
for (const [key, value] of Object.entries(environment("tui"))) command.push(`${key}=${value}`)
command.push(opencode.binary, "--server", url, "--session", session.id, workspace)
terminal.start(command, workspace)
await terminal.screen("startup", (text) => text.includes(sessionTitle) && text.includes("INSERT"), 60_000)

await run({ terminal, sessionTitle })
assert.equal((await request(`/api/session/${session.id}`)).data.id, session.id)
} finally {
process.off("SIGINT", interrupt)
process.off("SIGTERM", terminate)
stop()
if (server) await server.exited
for (const home of ["server", "tui"]) {
const log = path.join(directory, home, "data/opencode/log")
await cp(log, path.join(artifacts, `${home}-logs`), { recursive: true }).catch((error) => {
if (error.code !== "ENOENT") throw error
})
}
}

function environment(home: string): Record<string, string> {
const directory = path.join(options.directory, home)
return {
PATH: process.env.PATH!,
HOME: directory,
SHELL: "/bin/sh",
LANG: "en_US.UTF-8",
TERM: "tmux-256color",
COLORTERM: "truecolor",
OPENCODE_TEST_HOME: directory,
OPENCODE_CONFIG_DIR: path.join(directory, "config"),
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_DISABLE_PROJECT_CONFIG: "true",
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_PASSWORD: password,
XDG_CONFIG_HOME: path.join(directory, "xdg-config"),
XDG_DATA_HOME: path.join(directory, "data"),
XDG_CACHE_HOME: path.join(directory, "cache"),
XDG_STATE_HOME: path.join(directory, "state"),
}
}

async function request(endpoint: string, body?: unknown) {
const response = await fetch(url + endpoint, {
method: body === undefined ? "GET" : "POST",
headers: { authorization: `Basic ${btoa(`opencode:${password}`)}`, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(3_000),
})
if (!response.ok) throw new Error(`${endpoint}: ${response.status} ${await response.text()}`)
return response.json()
}
}
45 changes: 45 additions & 0 deletions test/e2e/opencode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { mkdir, mkdtemp, rename, rm } from "node:fs/promises"
import { execFileSync } from "node:child_process"
import path from "node:path"

export async function installOpenCode() {
if ((process.platform !== "linux" && process.platform !== "darwin") || (process.arch !== "x64" && process.arch !== "arm64")) {
throw new Error(`OpenCode E2E tests do not support ${process.platform}/${process.arch}`)
}
const release = await fetch("https://opencode.ai/update/api/latest/cli/npm", { signal: AbortSignal.timeout(30_000) })
if (!release.ok) throw new Error(`Could not resolve the latest OpenCode release: ${release.status}`)
const { version } = await release.json() as { version: string }
if (!/^2\.\d+\.\d+$/.test(version)) throw new Error(`Expected a stable OpenCode V2 release, received ${version}`)
console.log(`OpenCode E2E version: ${version} (latest stable)`)
const architecture = process.arch === "x64" ? "x64-baseline" : "arm64"
const target = `${process.platform}-${architecture}`
const cache = path.resolve(import.meta.dir, "../../node_modules/.cache/opencode")
const directory = path.join(cache, `${version}-${target}`)
const binary = path.join(directory, "opencode")
if (await Bun.file(binary).exists()) return { binary, version }

console.log(`Downloading OpenCode ${version} for E2E tests`)
await mkdir(cache, { recursive: true })
const temporary = await mkdtemp(path.join(cache, "download-"))
try {
const extension = process.platform === "darwin" ? "zip" : "tar.gz"
const archive = path.join(temporary, `opencode.${extension}`)
const response = await fetch(`https://opencode.ai/files/bin/${version}/opencode-${target}.${extension}`, {
signal: AbortSignal.timeout(120_000),
})
if (!response.ok) throw new Error(`Could not download OpenCode: ${response.status} ${response.statusText}`)
await Bun.write(archive, response)
if (extension === "zip") execFileSync("unzip", ["-q", archive, "-d", temporary])
else execFileSync("tar", ["-xzf", archive, "-C", temporary])
await rm(archive)
try {
await rename(temporary, directory)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "EEXIST" && code !== "ENOTEMPTY") throw error
}
} finally {
await rm(temporary, { recursive: true, force: true })
}
return { binary, version }
}
18 changes: 18 additions & 0 deletions test/e2e/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { execFileSync } from "node:child_process"
import { mkdir, symlink } from "node:fs/promises"
import path from "node:path"

export async function packPlugin(root: string, directory: string, artifacts: string) {
const manifest = await Bun.file(path.join(root, "package.json")).json()
const packed = execFileSync("npm", ["pack", "--quiet", "--pack-destination", directory], {
cwd: root,
encoding: "utf8",
timeout: 120_000,
})
await Bun.write(path.join(artifacts, "pack.log"), packed)
const plugin = path.join(directory, "node_modules", manifest.name)
await mkdir(plugin, { recursive: true })
execFileSync("tar", ["-xzf", path.join(directory, `${manifest.name}-${manifest.version}.tgz`), "--strip-components=1", "-C", plugin])
await symlink(path.join(root, "node_modules"), path.join(plugin, "node_modules"), "dir")
return path.join(plugin, "dist")
}
Loading
Loading