diff --git a/.gitignore b/.gitignore index cd82f14..63eb7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ tests/**/*.map *.tmp *.temp .debug80/ + +# E2E fixtures +!tests/e2e/fixtures/**/build/ +!tests/e2e/fixtures/**/build/* diff --git a/package.json b/package.json index 311e2b6..6fbbc17 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/platforms/tec1g/runtime.ts b/src/platforms/tec1g/runtime.ts index 69eed60..a90fafa 100644 --- a/src/platforms/tec1g/runtime.ts +++ b/src/platforms/tec1g/runtime.ts @@ -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; @@ -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, diff --git a/tests/e2e/adapter/adapter.e2e.test.ts b/tests/e2e/adapter/adapter.e2e.test.ts new file mode 100644 index 0000000..cee3694 --- /dev/null +++ b/tests/e2e/adapter/adapter.e2e.test.ts @@ -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 { + await client.sendRequest('initialize', { + adapterID: 'z80', + pathFormat: 'path', + linesStartAt1: true, + columnsStartAt1: true, + }); + await client.waitForEvent('initialized'); +} + +async function launchWithDiagnostics( + client: DapClient, + args: Record +): Promise { + 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'); + }); +}); diff --git a/tests/e2e/adapter/dap-client.ts b/tests/e2e/adapter/dap-client.ts new file mode 100644 index 0000000..86b1a31 --- /dev/null +++ b/tests/e2e/adapter/dap-client.ts @@ -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(); + 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( + command: string, + args?: Record, + timeoutMs = 5000 + ): Promise { + const seq = this.seq++; + const request: DapMessage & { arguments?: Record } = { + seq, + type: 'request', + command, + ...(args ? { arguments: args } : {}), + }; + return new Promise((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( + event: string, + predicate?: (payload: DapMessage) => boolean, + timeoutMs = 5000 + ): Promise { + 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((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); + } + } +} diff --git a/tests/e2e/adapter/vscode-mock.ts b/tests/e2e/adapter/vscode-mock.ts new file mode 100644 index 0000000..e4f3a07 --- /dev/null +++ b/tests/e2e/adapter/vscode-mock.ts @@ -0,0 +1,19 @@ +export type WorkspaceFolder = { uri: { fsPath: string } }; + +export const workspace: { workspaceFolders?: WorkspaceFolder[] } = { + workspaceFolders: undefined, +}; + +export const commands = { + executeCommand: (_command: string): Promise => Promise.resolve(false), +}; + +export class DebugAdapterInlineImplementation { + constructor(_session: unknown) { + // no-op stub for tests + } +} + +export type ProviderResult = T | undefined | null | Promise; + +export type DebugSession = unknown; diff --git a/tests/e2e/fixtures/simple/.vscode/debug80.json b/tests/e2e/fixtures/simple/.vscode/debug80.json new file mode 100644 index 0000000..5a8618e --- /dev/null +++ b/tests/e2e/fixtures/simple/.vscode/debug80.json @@ -0,0 +1,33 @@ +{ + "defaultTarget": "app", + "targets": { + "app": { + "sourceFile": "src/simple.asm", + "outputDir": "build", + "artifactBase": "simple", + "platform": "simple", + "assemble": false, + "hex": "build/simple.hex", + "listing": "build/simple.lst", + "simple": { + "regions": [ + { + "start": 0, + "end": 2047, + "kind": "rom" + }, + { + "start": 2048, + "end": 65535, + "kind": "ram" + } + ], + "appStart": 0, + "entry": 0 + }, + "sourceRoots": [ + "." + ] + } + } +} diff --git a/tests/e2e/fixtures/simple/.vscode/launch.json b/tests/e2e/fixtures/simple/.vscode/launch.json new file mode 100644 index 0000000..5234c3e --- /dev/null +++ b/tests/e2e/fixtures/simple/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "z80", + "request": "launch", + "name": "Debug80 E2E (Simple)", + "projectConfig": "${workspaceFolder}/.vscode/debug80.json", + "target": "app", + "stopOnEntry": true + } + ] +} diff --git a/tests/e2e/fixtures/simple/build/simple.d8dbg.json b/tests/e2e/fixtures/simple/build/simple.d8dbg.json new file mode 100644 index 0000000..b52fc2e --- /dev/null +++ b/tests/e2e/fixtures/simple/build/simple.d8dbg.json @@ -0,0 +1,56 @@ +{ + "format": "d8-debug-map", + "version": 1, + "arch": "z80", + "addressWidth": 16, + "endianness": "little", + "files": { + "src/simple.asm": { + "segments": [ + { + "start": 0, + "end": 1, + "line": 2, + "lstLine": 2, + "lstText": "NOP", + "kind": "code", + "confidence": "high" + }, + { + "start": 1, + "end": 3, + "line": 3, + "lstLine": 3, + "lstText": "IN A,(TERM_STATUS)", + "kind": "code", + "confidence": "high" + }, + { + "start": 3, + "end": 6, + "line": 5, + "lstLine": 5, + "lstText": "JP START", + "kind": "code", + "confidence": "high" + } + ], + "symbols": [ + { + "name": "START", + "address": 0, + "line": 1, + "kind": "label", + "scope": "global" + }, + { + "name": "VALUE", + "address": 3, + "line": 4, + "kind": "constant", + "scope": "global" + } + ] + } + } +} diff --git a/tests/e2e/fixtures/simple/build/simple.hex b/tests/e2e/fixtures/simple/build/simple.hex new file mode 100644 index 0000000..3793dc2 --- /dev/null +++ b/tests/e2e/fixtures/simple/build/simple.hex @@ -0,0 +1,2 @@ +:0600000000DB02C300005A +:00000001FF diff --git a/tests/e2e/fixtures/simple/build/simple.lst b/tests/e2e/fixtures/simple/build/simple.lst new file mode 100644 index 0000000..b6cd9d3 --- /dev/null +++ b/tests/e2e/fixtures/simple/build/simple.lst @@ -0,0 +1,7 @@ +0000 START: +0000 00 NOP +0001 DB 02 IN A,(TERM_STATUS) +0003 VALUE: EQU $0000 +0003 C3 00 00 JP START +START: 0000 DEFINED AT LINE 1 IN simple.asm +VALUE: 0003 DEFINED AT LINE 4 IN simple.asm diff --git a/tests/e2e/fixtures/simple/src/simple.asm b/tests/e2e/fixtures/simple/src/simple.asm new file mode 100644 index 0000000..d59f74b --- /dev/null +++ b/tests/e2e/fixtures/simple/src/simple.asm @@ -0,0 +1,5 @@ +START: + NOP + IN A,(TERM_STATUS) +VALUE: EQU $0000 + JP START diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts index 53182c7..15cd06c 100644 --- a/tests/extension/extension.test.ts +++ b/tests/extension/extension.test.ts @@ -88,7 +88,7 @@ describe('extension activation', () => { expect(registerCommand).toHaveBeenCalledWith('debug80.createProject', expect.anything()); expect(registerCommand).toHaveBeenCalledWith('debug80.openTerminal', expect.anything()); expect(context.subscriptions.length).toBeGreaterThan(0); - }); + }, 20000); it('forces asm documents to asm-collection when available', async () => { const extension = (await import('../../src/extension/extension')) as { @@ -109,5 +109,5 @@ describe('extension activation', () => { const docValue = doc as { uri?: { path?: string } }; expect(docValue.uri?.path).toBe('/tmp/test.asm'); expect(languageId).toBe('asm-collection'); - }); + }, 20000); }); diff --git a/vitest.config.ts b/vitest.config.ts index 8d7dc18..28a6606 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,10 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + exclude: ['tests/e2e/**', ...configDefaults.exclude], coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..33fc0e1 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +const cacheDir = path.resolve( + process.env.TMPDIR ?? '/tmp', + 'debug80-vitest-e2e-cache' +); + +export default defineConfig({ + resolve: { + alias: { + vscode: path.resolve(__dirname, 'tests/e2e/adapter/vscode-mock.ts'), + }, + }, + test: { + environment: 'node', + include: ['tests/e2e/**/*.test.ts'], + testTimeout: 20000, + hookTimeout: 20000, + cache: { + dir: cacheDir, + }, + }, +});