feat(vscode): extension VS Code native (intégration native, pas un webview)
Nouveau workspace packages/vscode (git-arboretum, privé, non publié sur npm), client REST/WS réutilisant @arboretum/shared. Auth Authorization: Bearer sur REST et l'upgrade WS (via `ws`) ; un client Node sans en-tête Origin passe le check Origin strict du serveur. - Arbres temps réel : Repositories (repos → worktrees → sessions) et Groups, via le WebSocket. - Terminaux natifs (vscode.Pseudoterminal) pour attacher/observer une session — rendu et scrollback de VS Code ; décodage UTF-8 streaming + comptabilité ACK dans des modules purs. - Status bar (compteur waiting) + notifications natives sur passage en waiting, réponses Yes/No via la commande WS answer. - Mutations git (create worktree, commit, push, promote), start/kill/hide/resume/fork, session de groupe ; conscience du workspace (reveal + start/create here). - Bundle esbuild (format cjs, external vscode) inlinant @arboretum/shared → VSIX autonome. Logique réutilisable sans import vscode → testée par vitest (19 tests). - CI : .gitea/workflows/vscode-release.yml package le VSIX sur tag vscode-vX.Y.Z (artefact + asset de release best-effort). build:vscode hors du build principal (comme le site). - spikes/s5-vscode/STUDY.md : décision de conception (GO phasé A→D), marquée implémentée.
This commit is contained in:
15
packages/vscode/test/config.test.ts
Normal file
15
packages/vscode/test/config.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeBaseUrl, wsUrlFromBase } from '../src/config.js';
|
||||
|
||||
describe('config', () => {
|
||||
it('normalise la base URL (slash final, défaut)', () => {
|
||||
expect(normalizeBaseUrl('http://127.0.0.1:7317/')).toBe('http://127.0.0.1:7317');
|
||||
expect(normalizeBaseUrl(' http://host:8080// ')).toBe('http://host:8080');
|
||||
expect(normalizeBaseUrl('')).toBe('http://127.0.0.1:7317');
|
||||
});
|
||||
|
||||
it('dérive l’URL WebSocket (/ws, http→ws, https→wss)', () => {
|
||||
expect(wsUrlFromBase('http://127.0.0.1:7317')).toBe('ws://127.0.0.1:7317/ws');
|
||||
expect(wsUrlFromBase('https://box.ts.net')).toBe('wss://box.ts.net/ws');
|
||||
});
|
||||
});
|
||||
51
packages/vscode/test/rest-client.test.ts
Normal file
51
packages/vscode/test/rest-client.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RestClient, RestError } from '../src/api/rest-client.js';
|
||||
|
||||
type Captured = { url: string; init: RequestInit };
|
||||
|
||||
function fakeFetch(response: { ok: boolean; status: number; body: unknown }, captured: Captured[]) {
|
||||
return vi.fn(async (url: string, init: RequestInit) => {
|
||||
captured.push({ url, init });
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
json: async () => response.body,
|
||||
} as unknown as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('RestClient', () => {
|
||||
it('appelle /api/v1/auth/me avec le header Bearer', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const rest = new RestClient(
|
||||
{ baseUrl: 'http://127.0.0.1:7317/', token: 'arb_secret' },
|
||||
fakeFetch({ ok: true, status: 200, body: { ok: true, tokenId: 't', tokenLabel: 'l', serverVersion: '1.8.0' } }, captured),
|
||||
);
|
||||
const me = await rest.me();
|
||||
expect(me.serverVersion).toBe('1.8.0');
|
||||
expect(captured[0]?.url).toBe('http://127.0.0.1:7317/api/v1/auth/me');
|
||||
const headers = captured[0]?.init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer arb_secret');
|
||||
});
|
||||
|
||||
it('normalise les erreurs serveur en RestError (code + status)', async () => {
|
||||
const rest = new RestClient(
|
||||
{ baseUrl: 'http://127.0.0.1:7317', token: 'bad' },
|
||||
fakeFetch({ ok: false, status: 401, body: { error: { code: 'BAD_TOKEN', message: 'invalid token' } } }, []),
|
||||
);
|
||||
await expect(rest.me()).rejects.toMatchObject({ code: 'BAD_TOKEN', status: 401 });
|
||||
await expect(rest.me()).rejects.toBeInstanceOf(RestError);
|
||||
});
|
||||
|
||||
it('encode les ids dans les chemins et envoie le corps JSON', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const rest = new RestClient(
|
||||
{ baseUrl: 'http://h:1', token: 't' },
|
||||
fakeFetch({ ok: true, status: 200, body: { worktree: {} } }, captured),
|
||||
);
|
||||
await rest.commitWorktree('repo id', { path: '/w', message: 'msg' });
|
||||
expect(captured[0]?.url).toBe('http://h:1/api/v1/repos/repo%20id/worktrees/commit');
|
||||
expect(captured[0]?.init.method).toBe('POST');
|
||||
expect(JSON.parse(String(captured[0]?.init.body))).toEqual({ path: '/w', message: 'msg' });
|
||||
});
|
||||
});
|
||||
24
packages/vscode/test/session-bridge.test.ts
Normal file
24
packages/vscode/test/session-bridge.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { StreamingUtf8Decoder } from '../src/terminal/session-bridge.js';
|
||||
|
||||
describe('StreamingUtf8Decoder', () => {
|
||||
it('recompose un caractère UTF-8 coupé en frontière de frame', () => {
|
||||
const dec = new StreamingUtf8Decoder();
|
||||
// 'é' = 0xC3 0xA9, coupé en deux frames
|
||||
expect(dec.decode(new Uint8Array([0xc3]))).toBe('');
|
||||
expect(dec.decode(new Uint8Array([0xa9]))).toBe('é');
|
||||
});
|
||||
|
||||
it('décode l’ASCII normalement', () => {
|
||||
const dec = new StreamingUtf8Decoder();
|
||||
expect(dec.decode(new Uint8Array([0x6c, 0x73, 0x0a]))).toBe('ls\n');
|
||||
});
|
||||
|
||||
it('reset() vide l’état partiel', () => {
|
||||
const dec = new StreamingUtf8Decoder();
|
||||
expect(dec.decode(new Uint8Array([0xe2, 0x9c]))).toBe(''); // début d’un ✓ (3 octets)
|
||||
dec.reset();
|
||||
// après reset, l’octet partiel est oublié → un nouveau caractère complet décode proprement
|
||||
expect(dec.decode(new Uint8Array([0x41]))).toBe('A');
|
||||
});
|
||||
});
|
||||
103
packages/vscode/test/store.test.ts
Normal file
103
packages/vscode/test/store.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { Store } from '../src/state/store.js';
|
||||
|
||||
function repo(id: string): RepoSummary {
|
||||
return {
|
||||
id,
|
||||
path: `/code/${id}`,
|
||||
label: id,
|
||||
defaultBranch: 'main',
|
||||
postCreateHooks: [],
|
||||
preTrust: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
valid: true,
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
function worktree(repoId: string, path: string, branch: string): WorktreeSummary {
|
||||
return {
|
||||
repoId,
|
||||
path,
|
||||
branch,
|
||||
head: 'abcdef0',
|
||||
detached: false,
|
||||
locked: false,
|
||||
prunable: false,
|
||||
isMain: branch === 'main',
|
||||
git: { ahead: 0, behind: 0, dirtyCount: 0, upstream: null },
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function session(id: string, cwd: string, over: Partial<SessionSummary> = {}): SessionSummary {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
command: 'claude',
|
||||
title: null,
|
||||
status: 'running',
|
||||
live: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
clients: 0,
|
||||
source: 'managed',
|
||||
claudeSessionId: null,
|
||||
pid: 123,
|
||||
resumable: false,
|
||||
attachable: true,
|
||||
registryStatus: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Store', () => {
|
||||
it('corrèle les sessions au worktree par cwd et filtre les externes', () => {
|
||||
const store = new Store();
|
||||
store.applyWorktree({ type: 'repo_update', repo: repo('app') });
|
||||
store.applyWorktree({ type: 'worktree_update', repoId: 'app', worktree: worktree('app', '/code/app', 'main') });
|
||||
|
||||
store.applySession({ type: 'session_update', session: session('s1', '/code/app') });
|
||||
store.applySession({ type: 'session_update', session: session('s2', '/code/app', { source: 'discovered' }) });
|
||||
|
||||
// externes masquées par défaut
|
||||
expect(store.sessionsForWorktree('/code/app').map((s) => s.id)).toEqual(['s1']);
|
||||
store.setShowExternalSessions(true);
|
||||
expect(store.sessionsForWorktree('/code/app').map((s) => s.id).sort()).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('liste les repos visibles, triés', () => {
|
||||
const store = new Store();
|
||||
store.applyWorktree({ type: 'repo_update', repo: { ...repo('b'), label: 'beta' } });
|
||||
store.applyWorktree({ type: 'repo_update', repo: { ...repo('a'), label: 'alpha' } });
|
||||
store.applyWorktree({ type: 'repo_update', repo: { ...repo('h'), label: 'hidden', hidden: true } });
|
||||
expect(store.listRepos().map((r) => r.label)).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('session_exit marque la session morte', () => {
|
||||
const store = new Store();
|
||||
store.applySession({ type: 'session_update', session: session('s1', '/x') });
|
||||
store.applySession({ type: 'session_exit', sessionId: 's1', exitCode: 0, signal: null });
|
||||
expect(store.getSession('s1')?.live).toBe(false);
|
||||
expect(store.getSession('s1')?.status).toBe('exited');
|
||||
});
|
||||
|
||||
it('waitingSessions ne retient que les sessions live en attente', () => {
|
||||
const store = new Store();
|
||||
store.applySession({ type: 'session_update', session: session('s1', '/x', { activity: 'waiting' }) });
|
||||
store.applySession({ type: 'session_update', session: session('s2', '/y', { activity: 'busy' }) });
|
||||
store.applySession({ type: 'session_update', session: session('s3', '/z', { activity: 'waiting', live: false, status: 'exited' }) });
|
||||
expect(store.waitingSessions().map((s) => s.id)).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('worktree_removed retire le worktree', () => {
|
||||
const store = new Store();
|
||||
store.applyWorktree({ type: 'repo_update', repo: repo('app') });
|
||||
store.applyWorktree({ type: 'worktree_update', repoId: 'app', worktree: worktree('app', '/code/app-wt', 'feat') });
|
||||
expect(store.listWorktrees('app')).toHaveLength(1);
|
||||
store.applyWorktree({ type: 'worktree_removed', repoId: 'app', path: '/code/app-wt' });
|
||||
expect(store.listWorktrees('app')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
136
packages/vscode/test/ws-client.test.ts
Normal file
136
packages/vscode/test/ws-client.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BINARY_FRAME, FLOW, encodeBinaryFrame, type ClientMessage } from '@arboretum/shared';
|
||||
import { ArbWsClient, type AttachmentSink, type RawSocket } from '../src/api/ws-client.js';
|
||||
|
||||
class FakeSocket implements RawSocket {
|
||||
sent: string[] = [];
|
||||
private openCb: (() => void) | null = null;
|
||||
private textCb: ((t: string) => void) | null = null;
|
||||
private binaryCb: ((d: Uint8Array) => void) | null = null;
|
||||
private closeCb: (() => void) | null = null;
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close(): void {
|
||||
this.closeCb?.();
|
||||
}
|
||||
onOpen(cb: () => void): void {
|
||||
this.openCb = cb;
|
||||
}
|
||||
onText(cb: (t: string) => void): void {
|
||||
this.textCb = cb;
|
||||
}
|
||||
onBinary(cb: (d: Uint8Array) => void): void {
|
||||
this.binaryCb = cb;
|
||||
}
|
||||
onClose(cb: () => void): void {
|
||||
this.closeCb = cb;
|
||||
}
|
||||
onError(): void {
|
||||
/* non utilisé dans ces tests */
|
||||
}
|
||||
|
||||
fireOpen(): void {
|
||||
this.openCb?.();
|
||||
}
|
||||
fireText(msg: unknown): void {
|
||||
this.textCb?.(JSON.stringify(msg));
|
||||
}
|
||||
fireBinary(data: Uint8Array): void {
|
||||
this.binaryCb?.(data);
|
||||
}
|
||||
|
||||
/** messages de contrôle envoyés, parsés et filtrés par type. */
|
||||
controls<T extends ClientMessage['type']>(type: T): Array<Extract<ClientMessage, { type: T }>> {
|
||||
return this.sent
|
||||
.map((s) => JSON.parse(s) as ClientMessage)
|
||||
.filter((m): m is Extract<ClientMessage, { type: T }> => m.type === type);
|
||||
}
|
||||
}
|
||||
|
||||
function makeNoopSink(): AttachmentSink {
|
||||
return { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
|
||||
}
|
||||
|
||||
describe('ArbWsClient', () => {
|
||||
let fake: FakeSocket;
|
||||
let client: ArbWsClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fake = new FakeSocket();
|
||||
client = new ArbWsClient({ socketFactory: () => fake });
|
||||
client.configure('http://127.0.0.1:7317', 'arb_token');
|
||||
client.connect();
|
||||
fake.fireOpen();
|
||||
fake.fireText({ type: 'hello_ok', protocol: 1, serverVersion: '1.8.0' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('envoie hello puis sub après hello_ok', () => {
|
||||
expect(fake.controls('hello')).toHaveLength(1);
|
||||
expect(fake.controls('sub')[0]?.topics).toEqual(['sessions', 'worktrees', 'groups']);
|
||||
});
|
||||
|
||||
it('corrèle attach→attached et délivre les OUTPUT au sink', async () => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const sink: AttachmentSink = { ...makeNoopSink(), data: (p) => chunks.push(p) };
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
|
||||
fake.fireText({ type: 'attached', channel: 1, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
const att = await p;
|
||||
expect(att.channel).toBe(1);
|
||||
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 1, new Uint8Array([104, 105]))); // "hi"
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect([...(chunks[0] ?? [])]).toEqual([104, 105]);
|
||||
});
|
||||
|
||||
it('ACK au seuil ACK_EVERY_BYTES', async () => {
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||||
fake.fireText({ type: 'attached', channel: 7, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
await p;
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||||
const acks = fake.controls('ack');
|
||||
expect(acks).toHaveLength(1);
|
||||
expect(acks[0]).toMatchObject({ channel: 7, bytes: FLOW.ACK_EVERY_BYTES });
|
||||
});
|
||||
|
||||
it('ACK traînant flushe un reliquat sous le seuil après 200ms', async () => {
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||||
fake.fireText({ type: 'attached', channel: 2, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
await p;
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 2, new Uint8Array(1000)));
|
||||
expect(fake.controls('ack')).toHaveLength(0); // sous le seuil → pas d’ACK immédiat
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(fake.controls('ack')).toEqual([{ type: 'ack', channel: 2, bytes: 1000 }]);
|
||||
});
|
||||
|
||||
it('RESYNC réinitialise le terminal et les compteurs d’ACK', async () => {
|
||||
let resets = 0;
|
||||
const sink: AttachmentSink = { ...makeNoopSink(), reset: () => (resets += 1) };
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
|
||||
fake.fireText({ type: 'attached', channel: 3, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
await p;
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.RESYNC, 3, new Uint8Array([65]))); // reset + replay "A"
|
||||
expect(resets).toBe(1);
|
||||
// après resync, le replay ne compte pas : un nouvel OUTPUT au seuil ré-ACK à partir de 0
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||||
const acks = fake.controls('ack');
|
||||
expect(acks[acks.length - 1]).toMatchObject({ bytes: FLOW.ACK_EVERY_BYTES });
|
||||
});
|
||||
|
||||
it('envoie stdin/resize sur le bon canal', async () => {
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||||
fake.fireText({ type: 'attached', channel: 5, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
const att = await p;
|
||||
att.sendStdin('ls\n');
|
||||
att.resize(120, 40);
|
||||
expect(fake.controls('stdin')[0]).toEqual({ type: 'stdin', channel: 5, data: 'ls\n' });
|
||||
expect(fake.controls('resize')[0]).toEqual({ type: 'resize', channel: 5, cols: 120, rows: 40 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user