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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ tests/**/*.map
*.tmp
*.temp
.debug80/

# E2E fixtures
!tests/e2e/fixtures/**/build/
!tests/e2e/fixtures/**/build/*
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,9 @@
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"perf:z80": "yarn build && node scripts/perf-z80.js",
"package": "vsce package"
"package": "vsce package",
"test:e2e:adapter": "vitest run -c vitest.e2e.config.ts",
"test:e2e": "yarn test:e2e:adapter"
},
"devDependencies": {
"@types/node": "^20.10.0",
Expand Down
11 changes: 6 additions & 5 deletions src/platforms/tec1g/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export function createTec1gRuntime(
const initialSysCtrl =
(config.expansionBankHi ? TEC1G_SYSCTRL_BANK_A14 : 0) |
(config.protectOnReset ? TEC1G_SYSCTRL_PROTECT : 0);
const initialSysCtrlDecoded = decodeSysCtrl(initialSysCtrl);
const matrixMode = config.matrixMode;
const rtcEnabled = config.rtcEnabled;
const rtc = rtcEnabled ? new Ds1302() : null;
Expand Down Expand Up @@ -425,11 +426,11 @@ export function createTec1gRuntime(
updateMs: config.updateMs,
yieldMs: config.yieldMs,
sysCtrl: initialSysCtrl,
shadowEnabled: true,
protectEnabled: false,
expandEnabled: false,
bankA14: config.expansionBankHi,
capsLock: false,
shadowEnabled: initialSysCtrlDecoded.shadowEnabled,
protectEnabled: initialSysCtrlDecoded.protectEnabled,
expandEnabled: initialSysCtrlDecoded.expandEnabled,
bankA14: initialSysCtrlDecoded.bankA14,
capsLock: initialSysCtrlDecoded.capsLock,
cartridgePresent: cartridgePresentDefault,
shiftKeyActive: false,
rawKeyActive: false,
Expand Down
130 changes: 130 additions & 0 deletions tests/e2e/adapter/adapter.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { PassThrough } from 'stream';
import path from 'path';
import { Z80DebugSession } from '../../../src/debug/adapter';
import { DapClient } from './dap-client';
import { workspace } from './vscode-mock';

const fixtureRoot = path.resolve(__dirname, '../fixtures/simple');
const projectConfig = path.join(fixtureRoot, '.vscode', 'debug80.json');
const sourcePath = path.join(fixtureRoot, 'src', 'simple.asm');

const THREAD_ID = 1;

type SessionHarness = {
session: Z80DebugSession;
client: DapClient;
input: PassThrough;
output: PassThrough;
};

function createHarness(): SessionHarness {
const input = new PassThrough();
const output = new PassThrough();
const session = new Z80DebugSession();
session.setRunAsServer(true);
session.start(input, output);
const client = new DapClient(input, output);
return { session, client, input, output };
}

async function initialize(client: DapClient): Promise<void> {
await client.sendRequest('initialize', {
adapterID: 'z80',
pathFormat: 'path',
linesStartAt1: true,
columnsStartAt1: true,
});
await client.waitForEvent('initialized');
}

async function launchWithDiagnostics(
client: DapClient,
args: Record<string, unknown>
): Promise<void> {
try {
await client.sendRequest('launch', args);
} catch (err) {
let output = '';
try {
const event = await client.waitForEvent<{ body?: { output?: string } }>('output', undefined, 1000);
output = event.body?.output?.trim() ?? '';
} catch {
// ignore missing output
}
const message = err instanceof Error ? err.message : String(err);
const detail = output.length > 0 ? `${message}\n${output}` : message;
throw new Error(detail);
}
}
describe('Debug80 adapter e2e (in-process)', () => {
let harness: SessionHarness | undefined;

beforeEach(() => {
workspace.workspaceFolders = [{ uri: { fsPath: fixtureRoot } }];
harness = createHarness();
});

afterEach(() => {
if (!harness) {
return;
}
harness.client.dispose();
harness.input.end();
harness.output.end();
harness = undefined;
});

it('stops on entry when stopOnEntry is true', async () => {
const { client } = harness ?? createHarness();

await initialize(client);
await launchWithDiagnostics(client, {
projectConfig,
target: 'app',
stopOnEntry: true,
openRomSourcesOnLaunch: false,
openMainSourceOnLaunch: false,
});
await client.sendRequest('setBreakpoints', {
source: { path: sourcePath },
breakpoints: [],
});
await client.sendRequest('configurationDone');

const stopped = await client.waitForEvent<{ body?: { reason?: string } }>('stopped');
expect(stopped.body?.reason).toBe('entry');

await client.sendRequest('disconnect');
});

it('hits a source breakpoint after configurationDone', async () => {
const { client } = harness ?? createHarness();

await initialize(client);
await launchWithDiagnostics(client, {
projectConfig,
target: 'app',
stopOnEntry: false,
openRomSourcesOnLaunch: false,
openMainSourceOnLaunch: false,
});
await client.sendRequest('setBreakpoints', {
source: { path: sourcePath },
breakpoints: [{ line: 2 }],
});
await client.sendRequest('configurationDone');

const stopped = await client.waitForEvent<{ body?: { reason?: string } }>('stopped');
expect(stopped.body?.reason).toBe('breakpoint');

const stack = await client.sendRequest<{
body?: { stackFrames?: Array<{ line: number; source?: { path?: string } }> };
}>('stackTrace', { threadId: THREAD_ID, startFrame: 0, levels: 1 });
const frame = stack.body?.stackFrames?.[0];
expect(frame?.line).toBe(2);
expect(frame?.source?.path).toBe(sourcePath);

await client.sendRequest('disconnect');
});
});
200 changes: 200 additions & 0 deletions tests/e2e/adapter/dap-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { Readable, Writable } from 'stream';

type DapMessage = {
seq: number;
type: 'request' | 'response' | 'event';
command?: string;
event?: string;
request_seq?: number;
success?: boolean;
message?: string;
body?: unknown;
};

type PendingRequest = {
resolve: (value: DapMessage) => void;
reject: (reason?: Error) => void;
timer?: NodeJS.Timeout;
};

type PendingEvent = {
event: string;
predicate?: (payload: DapMessage) => boolean;
resolve: (value: DapMessage) => void;
reject: (reason?: Error) => void;
timer?: NodeJS.Timeout;
};

export class DapClient {
private readonly input: Writable;
private readonly output: Readable;
private readonly pending = new Map<number, PendingRequest>();
private readonly eventWaiters: PendingEvent[] = [];
private readonly eventQueue: DapMessage[] = [];
private buffer = Buffer.alloc(0);
private seq = 1;

constructor(input: Writable, output: Readable) {
this.input = input;
this.output = output;
this.output.on('data', (chunk: Buffer | string | Uint8Array) => this.onData(chunk));
}

async sendRequest<T = DapMessage>(
command: string,
args?: Record<string, unknown>,
timeoutMs = 5000
): Promise<T> {
const seq = this.seq++;
const request: DapMessage & { arguments?: Record<string, unknown> } = {
seq,
type: 'request',
command,
...(args ? { arguments: args } : {}),
};
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(seq);
reject(new Error(`Timeout waiting for response to ${command}`));
}, timeoutMs);
this.pending.set(seq, {
resolve: (msg) => resolve(msg as T),
reject,
timer,
});
try {
this.writeMessage(request);
} catch (err) {
clearTimeout(timer);
this.pending.delete(seq);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}

waitForEvent<T = DapMessage>(
event: string,
predicate?: (payload: DapMessage) => boolean,
timeoutMs = 5000
): Promise<T> {
const existingIndex = this.eventQueue.findIndex((msg) => {
if (msg.type !== 'event' || msg.event !== event) {
return false;
}
return predicate ? predicate(msg) : true;
});
if (existingIndex >= 0) {
const [msg] = this.eventQueue.splice(existingIndex, 1);
return Promise.resolve(msg as T);
}

return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
const idx = this.eventWaiters.indexOf(waiter);
if (idx >= 0) {
this.eventWaiters.splice(idx, 1);
}
reject(new Error(`Timeout waiting for event ${event}`));
}, timeoutMs);
const waiter: PendingEvent = {
event,
predicate,
resolve: (msg) => resolve(msg as T),
reject,
timer,
};
this.eventWaiters.push(waiter);
});
}

dispose(): void {
for (const [, pending] of this.pending) {
if (pending.timer) {
clearTimeout(pending.timer);
}
pending.reject(new Error('Client disposed'));
}
this.pending.clear();
for (const waiter of this.eventWaiters) {
if (waiter.timer) {
clearTimeout(waiter.timer);
}
waiter.reject(new Error('Client disposed'));
}
this.eventWaiters.splice(0, this.eventWaiters.length);
}

private writeMessage(message: DapMessage & { arguments?: unknown }): void {
const json = JSON.stringify(message);
const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
this.input.write(header + json, 'utf8');
}

private onData(chunk: Buffer | string | Uint8Array): void {
const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
this.buffer = Buffer.concat([this.buffer, next]);
while (this.buffer.length > 0) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const header = this.buffer.slice(0, headerEnd).toString('utf8');
const match = /Content-Length: (\d+)/i.exec(header);
if (!match) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const length = Number.parseInt(match[1] ?? '0', 10);
const bodyStart = headerEnd + 4;
const bodyEnd = bodyStart + length;
if (this.buffer.length < bodyEnd) {
return;
}
const body = this.buffer.slice(bodyStart, bodyEnd).toString('utf8');
this.buffer = this.buffer.slice(bodyEnd);
const message = JSON.parse(body) as DapMessage;
this.handleMessage(message);
}
}

private handleMessage(message: DapMessage): void {
if (message.type === 'response') {
const requestSeq = message.request_seq;
if (requestSeq === undefined) {
return;
}
const pending = this.pending.get(requestSeq);
if (!pending) {
return;
}
if (pending.timer) {
clearTimeout(pending.timer);
}
this.pending.delete(requestSeq);
if (message.success === false) {
pending.reject(new Error(message.message ?? 'DAP error response'));
} else {
pending.resolve(message);
}
return;
}

if (message.type === 'event') {
const waiterIndex = this.eventWaiters.findIndex((waiter) => {
if (waiter.event !== message.event) {
return false;
}
return waiter.predicate ? waiter.predicate(message) : true;
});
if (waiterIndex >= 0) {
const waiter = this.eventWaiters.splice(waiterIndex, 1)[0];
if (waiter.timer) {
clearTimeout(waiter.timer);
}
waiter.resolve(message);
return;
}
this.eventQueue.push(message);
}
}
}
19 changes: 19 additions & 0 deletions tests/e2e/adapter/vscode-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type WorkspaceFolder = { uri: { fsPath: string } };

export const workspace: { workspaceFolders?: WorkspaceFolder[] } = {
workspaceFolders: undefined,
};

export const commands = {
executeCommand: (_command: string): Promise<boolean> => Promise.resolve(false),
};

export class DebugAdapterInlineImplementation {
constructor(_session: unknown) {
// no-op stub for tests
}
}

export type ProviderResult<T> = T | undefined | null | Promise<T | undefined | null>;

export type DebugSession = unknown;
Loading