diff --git a/.github/workflows/native-sidebar.yml b/.github/workflows/native-sidebar.yml index 2bfc43be..ca9c35a4 100644 --- a/.github/workflows/native-sidebar.yml +++ b/.github/workflows/native-sidebar.yml @@ -39,3 +39,62 @@ jobs: run: | npm install --prefix "$RUNNER_TEMP/native-host" --package-lock=false @opencode/cli@2.0.18 OPENCODE2_BINARY="$RUNNER_TEMP/native-host/node_modules/.bin/opencode2" node scripts/native-status-host-canary.mjs + + strict-consumer: + name: Strict npm Goal TUI + OpenCode ${{ matrix.host }} / OpenTUI ${{ matrix.opentui }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + opentui: '0.5.14' + host: '2.0.18' + - os: windows-latest + opentui: '0.5.14' + host: '2.0.18' + - os: ubuntu-latest + opentui: '0.7.0' + host: '2.1.4' + - os: windows-latest + opentui: '0.7.0' + host: '2.1.4' + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: '1.4.2' + - name: Build candidate without changing published version + run: | + npm install + npm run check + npm run build + - name: Clean strictly resolved package install + real TUI render + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/goal-packages" + npm pack --json --ignore-scripts --pack-destination "$RUNNER_TEMP/goal-packages" > "$RUNNER_TEMP/goal-pack.json" + filename="$(node -e 'const fs=require("node:fs");const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(p.length!==1)process.exit(1);process.stdout.write(p[0].filename)' "$RUNNER_TEMP/goal-pack.json")" + consumer="$RUNNER_TEMP/goal-clean-consumer" + mkdir -p "$consumer" + printf '{"private":true,"type":"module"}\n' > "$consumer/package.json" + # Strict npm resolution: NEVER --legacy-peer-deps, --force, + # --omit=peer or a developer-checkout node_modules link. + npm install --prefix "$consumer" --no-audit --no-fund --package-lock=false \ + "$RUNNER_TEMP/goal-packages/$filename" \ + "@opencode/cli@${{ matrix.host }}" \ + "@opentui/core@${{ matrix.opentui }}" \ + "@opentui/solid@${{ matrix.opentui }}" \ + solid-js@1.9.12 + npm ls --prefix "$consumer" @opencode/cli @opencode/plugin @bybrawe/opencode-goal @opentui/core @opentui/solid solid-js + node -e 'const p=require(process.argv[1]);if(p.version!==process.argv[2])process.exit(1);console.log("Host OpenCode CLI",p.version)' "$consumer/node_modules/@opencode/cli/package.json" "${{ matrix.host }}" + node -e 'const p=require(process.argv[1]);if(p.version!==process.argv[2])process.exit(1);console.log("OpenTUI Solid",p.version)' "$consumer/node_modules/@opentui/solid/package.json" "${{ matrix.opentui }}" + cp scripts/native-tui-clean-consumer-smoke.mjs "$consumer/native-tui-smoke.mjs" + (cd "$consumer" && bun --conditions=browser native-tui-smoke.mjs) diff --git a/package.json b/package.json index cc7019cb..90036d59 100644 --- a/package.json +++ b/package.json @@ -127,8 +127,8 @@ "solid-js": "1.9.12" }, "peerDependencies": { - "@opentui/core": ">=0.5.14 <0.6", - "@opentui/solid": ">=0.5.14 <0.6", + "@opentui/core": ">=0.5.14 <0.8", + "@opentui/solid": ">=0.5.14 <0.8", "solid-js": ">=1.9.0 <2" }, "peerDependenciesMeta": { diff --git a/scripts/native-tui-clean-consumer-smoke.mjs b/scripts/native-tui-clean-consumer-smoke.mjs new file mode 100644 index 00000000..25e63525 --- /dev/null +++ b/scripts/native-tui-clean-consumer-smoke.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import plugin from "@bybrawe/opencode-goal/tui" + +// This file runs only after a fresh strict npm installation of the packed +// candidate into a *different* directory. None of the checkout's dev +// dependencies should resolve from this consumer location. +let slot +let listener +let unregistered = 0 +let unsubscribed = 0 +const requests = [] +const [selected, select] = createSignal("ses_first") +const project = (id) => "/remote/project/" + id + +const ctx = { + client: { rpc: (definition) => { + assert.equal(definition.id, "opencode-goal-status") + return { read: async ({ sessionID }, options) => { + requests.push({ sessionID, location: options.location }) + return { + schemaVersion: 1, sessionID, + directory: options.location.directory, + text: "OpenCode Goals\nACTIVE " + sessionID, + } + } } + } }, + theme: { text: { base: "#ffffff" } }, + data: { + session: { get: (id) => ({ id, location: { directory: project(id), workspaceID: "w1" } }) }, + listen: (callback) => { listener = callback; return () => { unsubscribed++ } }, + }, + ui: { slot: (contribution) => { + assert.equal(contribution.append, "sidebar.content") + slot = contribution + return () => { unregistered++ } + } }, +} + +assert.equal(plugin.id, "opencode-goal", "native TUI plugin identity must match its established public registration") +const close = await plugin.setup(ctx) +assert.ok(slot) +const rendered = await testRender( + () => slot.render({ get sessionID() { return selected() } }), + { width: 60, height: 12 }, +) + +try { + await new Promise((r) => setTimeout(r, 120)) + await rendered.renderOnce() + assert.match(rendered.captureCharFrame(), /OpenCode Goals/) + assert.match(rendered.captureCharFrame(), /ACTIVE ses_first/) + select("ses_second") + await new Promise((r) => setTimeout(r, 120)) + await rendered.renderOnce() + assert.match(rendered.captureCharFrame(), /ACTIVE ses_second/) + assert.doesNotMatch(rendered.captureCharFrame(), /ACTIVE ses_first/) + assert.equal(requests.at(-1).location.directory, project("ses_second")) + assert.equal(requests.at(-1).location.workspaceID, "w1") + + close() + close() + const previous = requests.length + listener({ details: { type: "server.connected" } }) + await new Promise((r) => setTimeout(r, 135)) + assert.equal(requests.length, previous, "disposed sidebar must not keep polling") + assert.equal(unregistered, 1, "slot unregisters exactly once") + assert.equal(unsubscribed, 1, "listener unsubscribes exactly once") + console.log("Packed Goal strict npm consumer: OpenTUI visible sidebar, session switch and teardown PASS") +} finally { + close() + rendered.renderer.destroy() +}