Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fe9d58d
SUnit: one runner path, a result store, and gutter-ready test items
ericwinger Aug 19, 2026
6650481
SUnit: debug a test class or test method through the shared debugger …
ericwinger Aug 19, 2026
938eea5
Merge remote-tracking branch 'origin/main' into eric/issue427-sunit-run
ericwinger Aug 20, 2026
2720083
URI: let a method category carry a slash, like a selector already can
ericwinger Aug 20, 2026
4c338e7
Non-blocking calls: survive a cancel, and a session that goes away
ericwinger Aug 20, 2026
5a90050
SUnit: run tests without freezing the editor, and let a run be stopped
ericwinger Aug 20, 2026
43f1a41
Run and watch SUnit tests from the GemStone Explorer
ericwinger Aug 20, 2026
e046e17
GemStone Search: Shift+Enter goes to the result's test
ericwinger Aug 20, 2026
9966275
Docs: the Explorer can run tests now, so stop calling it a gap
ericwinger Aug 20, 2026
9336bfa
Merge remote-tracking branch 'origin/main' into eric/issue427-sunit-run
ericwinger Aug 20, 2026
aa1a9ed
Non-blocking calls: three ways a break left the session unusable
ericwinger Aug 20, 2026
4b14434
Cover the rest of the SUnit work, including against a live stone
ericwinger Aug 20, 2026
0a025c9
Fix two ways the drain misbehaved under the whole suite
ericwinger Aug 20, 2026
28330fa
SUnit: don't pass a class that ran nothing, or blame a cancel on the …
ericwinger Aug 21, 2026
1bda47c
SUnit: scope staleness to the edited class, and dim stale rows instea…
ericwinger Aug 21, 2026
12137bc
SUnit: run setUp inside the debug ensure block so tearDown still runs
ericwinger Aug 21, 2026
11ed508
SUnit review cleanup: fix the attributed-open leak, dedupe the test r…
ericwinger Aug 21, 2026
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,14 @@ The extension integrates with VS Code's native Test Explorer:

- Auto-discovers all `TestCase` subclasses and their `test*` methods
- Run individual tests or entire test classes
- Debug a test, or a whole class, under the GemStone debugger — a debug run omits SUnit's exception handler, so a failing test suspends on a live stack instead of being recorded and discarded
- Pass/fail/error results with failure messages
- Test items link to method source
- Stop a long-running test: the Testing view's stop button, the ■ that replaces a row's ▶ in the Explorer while it runs, or the Cancel on the progress notification. First press asks the gem to stop at a safe point; a second interrupts it
- Test items link to method source, and a run/status icon appears in the editor gutter beside an open test class or test method
- Run from the GemStone Explorer too: a ▶ on a test class row (Classes or Hierarchy pane) or a test method row (Methods pane), each showing the outcome of its last run
- Clicking a row in the Testing view opens the code without moving the Explorer; **Reveal in GemStone Explorer** — the ⊟ on the row, or its context menu — navigates the panes when you want it
- The reverse too: **Reveal in Testing View** on a test class or test method row in the Explorer selects it in the Testing view
- **Clear Test Results** wipes the outcome icons — in the Explorer and in the Testing view both — when a run in progress is hard to see. Right-click a test row in either place, or use the `…` overflow on the Classes / Hierarchy / Methods pane headers, or the Command Palette

### Jupyter Notebooks (Smalltalk and Grail Python)

Expand Down
30 changes: 29 additions & 1 deletion client/src/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,35 @@ export const TreeItemCollapsibleState = {
// ── ThemeIcon mock ─────────────────────────────────────────

export class ThemeIcon {
constructor(public readonly id: string) {}
constructor(
public readonly id: string,
// Real ThemeIcon takes an optional ThemeColor; kept here so a test can assert
// which colour a row was painted (pass vs. failed vs. stale).
public readonly color?: { id: string },
) {}
}

// ── CancellationTokenSource mock ───────────────────────────

export class CancellationTokenSource {
private listeners: Array<() => void> = [];

readonly token = {
isCancellationRequested: false,
onCancellationRequested: (listener: () => void) => {
this.listeners.push(listener);
return { dispose: () => {} };
},
};

cancel(): void {
this.token.isCancellationRequested = true;
for (const listener of this.listeners) listener();
}

dispose(): void {
this.listeners = [];
}
}

// ── EventEmitter mock ──────────────────────────────────────
Expand Down
96 changes: 96 additions & 0 deletions client/src/__tests__/codeExecutor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ function makeGci(overrides: Record<string, unknown> = {}) {
err: { number: 0, message: '' },
})),
GciTsNbPoll: vi.fn(() => ({ result: 1, err: { number: 0 } })),
// The poll checks the session is still there to answer; -1 would mean it is gone.
GciTsCallInProgress: vi.fn(() => ({ result: 0, err: { number: 0 } })),
isAvailable: vi.fn(() => true),
GciTsSocket: vi.fn(() => ({ fd: 7, err: { number: 0 } })),
GciTsNbResult: vi.fn((): Record<string, unknown> => ({
Expand Down Expand Up @@ -1619,4 +1621,98 @@ describe('CodeExecutor', () => {
expect(diags[0].message).toContain('no socket');
});
});
describe('executeWithDebugger', () => {
// The debug-execution primitive a SUnit test run borrows: no editor, no
// result rendering — just "run this with the debugger enabled and tell me
// whether it raised".
it('runs the code with the debugger enabled and answers that it did not raise', async () => {
const gci = makeGci();
const session = makeSession(gci);
const executor = new CodeExecutor(makeSessionManager(session));

const outcome = await executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testAdd');

expect(outcome).toEqual({ raised: false });
const flags = (gci.GciTsNbExecute as Mock).mock.calls[0][5] as number;
expect(flags & GCI_PERFORM_FLAG_ENABLE_DEBUG).toBe(GCI_PERFORM_FLAG_ENABLE_DEBUG);
expect((gci.GciTsNbExecute as Mock).mock.calls[0][1]).toBe('3 + 4');
});

it('needs no active editor — a test is debugged from a row, not from text', async () => {
(vscode.window as unknown as Record<string, unknown>).activeTextEditor = undefined;
const session = makeSession();
const executor = new CodeExecutor(makeSessionManager(session));

await expect(
executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testAdd'),
).resolves.toEqual({ raised: false });
});

it('offers the suspended process to a debugger and reports that it raised', async () => {
const gci = makeGci({
GciTsNbResult: vi.fn(() => ({
result: 0n,
err: { number: 2010, message: 'doesNotUnderstand: #foo', context: 999n },
})),
});
const session = makeSession(gci);
const executor = new CodeExecutor(makeSessionManager(session));

const outcome = await executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testAdd');

expect(outcome).toEqual({ raised: true, message: 'doesNotUnderstand: #foo' });
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
expect.stringContaining('doesNotUnderstand: #foo'),
{ modal: true },
'Enhanced Debug',
'Debug',
);
});

it('reports a soft-break cancel as cancelled, without opening a debugger', async () => {
// A soft break (error 6003) only arrives because the user cancelled the
// run. It says nothing about the code, so it must not be offered to a
// debugger or reported as a raise.
const gci = makeGci({
GciTsNbResult: vi.fn(() => ({
result: 0n,
err: { number: 6003, message: 'A soft break was received.', context: 999n },
})),
});
const session = makeSession(gci);
const executor = new CodeExecutor(makeSessionManager(session));

const outcome = await executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testSlow');

expect(outcome).toEqual({ raised: false, cancelled: true });
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
});

it('releases the session lock so the next test can run', async () => {
const session = makeSession();
const executor = new CodeExecutor(makeSessionManager(session));

await executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testAdd');
await executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testRemove');

expect((session.gci.GciTsNbExecute as Mock).mock.calls).toHaveLength(2);
});

it('refuses to start on a session that is already executing', async () => {
const session = makeSession();
const executor = new CodeExecutor(makeSessionManager(session));
// Hold the first execution open so the session is genuinely busy.
(session.gci.GciTsNbPoll as Mock).mockReturnValue({ result: 0, err: { number: 0 } });
const pending = executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testAdd');

await expect(
executor.executeWithDebugger(session, '3 + 4', 'MyTest>>testRemove'),
).rejects.toThrow(/already in progress/);

// Let it finish: a poll loop still spinning at the end of this test would
// be drained by whichever fake-timer test runs next.
(session.gci.GciTsNbPoll as Mock).mockReturnValue({ result: 1, err: { number: 0 } });
await pending;
});
});
});
46 changes: 46 additions & 0 deletions client/src/__tests__/debugTestMethod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';

import { debugTestMethodCode } from '../queries/debugTestMethod';

describe('debugTestMethodCode', () => {
// The whole reason a debug run can't reuse the ordinary run queries is that
// those install an exception handler. If one ever appears here, a failing
// test silently stops being debuggable — hence the explicit assertions.
it('installs no exception handler around the test', () => {
const code = debugTestMethodCode('MyTestCase', 'testAdd', 'UserGlobals');
expect(code).not.toContain('on: AbstractException');
expect(code).not.toContain('TestFailure');
});

it('runs setUp, the test, and tearDown', () => {
const code = debugTestMethodCode('MyTestCase', 'testAdd', 'UserGlobals');
expect(code).toContain('tc setUp');
expect(code).toContain("tc perform: #'testAdd'");
expect(code).toContain('ensure: [tc tearDown]');
});

it('runs setUp inside the ensure block, so tearDown runs even when setUp raises', () => {
// Regression: setUp used to sit as a bare statement before the ensure:, so a
// setUp that raised skipped tearDown — the exact case a debug run exists for.
// Wrapping setUp too matches GemStone's own TestCase>>runCase.
const code = debugTestMethodCode('MyTestCase', 'testAdd', 'UserGlobals');
expect(code).toContain("[tc setUp. tc perform: #'testAdd'] ensure: [tc tearDown]");
// And never the old, unprotected shape.
expect(code).not.toMatch(/tc setUp\.\s*\n\s*\[tc perform/);
});

it('resolves the class in the dictionary it was found in', () => {
const code = debugTestMethodCode('MyTestCase', 'testAdd', 'UserGlobals');
expect(code).toContain('UserGlobals');
});

it('answers a value when nothing raised, so a pass is distinguishable', () => {
const code = debugTestMethodCode('MyTestCase', 'testAdd', 'UserGlobals');
expect(code.trimEnd().endsWith("'passed'")).toBe(true);
});

it('escapes a quote in the selector rather than breaking the literal', () => {
const code = debugTestMethodCode('MyTestCase', "test'Odd", 'UserGlobals');
expect(code).toContain("#'test''Odd'");
});
});
Loading