From e5f2555cf686e526758138df8aeec8b02d356f37 Mon Sep 17 00:00:00 2001 From: Arsym <123092893+arsym-dev@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:19:32 -0400 Subject: [PATCH] Test explorer JSONL streaming --- package.json | 63 +++++- src/extension.ts | 42 ++-- src/renpy-command.ts | 113 +++++++++++ src/task-provider.ts | 8 +- src/testing/controller.ts | 177 ++++++++++++++++ src/testing/discovery.ts | 300 ++++++++++++++++++++++++++++ src/testing/event-mapper.ts | 132 ++++++++++++ src/testing/event-schema.ts | 120 +++++++++++ src/testing/id-map.ts | 21 ++ src/testing/run-executor.ts | 291 +++++++++++++++++++++++++++ src/testing/settings.ts | 49 +++++ src/testing/stream-poller.ts | 189 ++++++++++++++++++ syntaxes/renpy.test.tmLanguage.yaml | 8 +- 13 files changed, 1485 insertions(+), 28 deletions(-) create mode 100644 src/renpy-command.ts create mode 100644 src/testing/controller.ts create mode 100644 src/testing/discovery.ts create mode 100644 src/testing/event-mapper.ts create mode 100644 src/testing/event-schema.ts create mode 100644 src/testing/id-map.ts create mode 100644 src/testing/run-executor.ts create mode 100644 src/testing/settings.ts create mode 100644 src/testing/stream-poller.ts diff --git a/package.json b/package.json index 58823030..a74e624d 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "activationEvents": [ "workspaceContains:**/*.rpy", "workspaceContains:**/_ren.py", - "onDebugResolve:renpy" + "onDebugResolve:renpy", + "onStartupFinished" ], "main": "./dist/extension", "browser": "./dist/extension.js", @@ -311,7 +312,65 @@ "renpy.renpyExecutableLocation": { "type": "string", "default": "", - "description": "Location of Ren'Py installation. Should be .exe on Windows and .sh on Mac/Linux." + "description": "Ren'Py launch command. You can provide an executable path (renpy.exe on Windows, renpy.sh on Linux/macOS), or an executable plus startup args (for example: /path/lib/py3-windows-x86_64/python.exe renpy.py)." + }, + "renpy.testing.renpyExecutableOverride": { + "type": "string", + "default": "", + "description": "Override Ren'Py launch command used for test runs. Supports executable plus startup args. If empty, 'renpy.renpyExecutableLocation' is used." + }, + "renpy.testing.reporters": { + "type": "string", + "default": "console", + "description": "Comma-separated reporter list passed to --reporter for test runs. Use 'console' by default. `jsonl` is always included to allow the Test Explorer integration to function." + }, + "renpy.testing.jsonlOutputPath": { + "type": "string", + "default": "", + "description": "Directory for per-run JSONL stream output files. Defaults to the system temp directory." + }, + "renpy.testing.jsonlPollInterval": { + "type": "number", + "default": 500, + "minimum": 100, + "maximum": 5000, + "description": "Polling interval in milliseconds for reading JSONL stream events during test runs." + }, + "renpy.testing.jsonlBatchSize": { + "type": "number", + "default": 50, + "minimum": 1, + "maximum": 500, + "description": "Number of test result events per batch emitted by the JSONL stream reporter." + }, + "renpy.testing.jsonlFlushInterval": { + "type": "number", + "default": 1000, + "minimum": 100, + "maximum": 10000, + "description": "Reporter flush interval in milliseconds for JSONL stream output." + }, + "renpy.testing.discoveryRefreshMode": { + "type": "string", + "default": "cache-first", + "enum": ["always-refresh", "cache-first"], + "enumDescriptions": [ + "Always run Ren'Py compile/dump before discovery to get fresh metadata.", + "Use existing navigation.json if present and fresh; refresh only when stale." + ], + "description": "Controls when navigation.json is refreshed during test discovery." + }, + "renpy.testing.jsonlStalledTimeout": { + "type": "number", + "default": 5000, + "minimum": 1000, + "maximum": 60000, + "description": "Stalled-run timeout in milliseconds. If no event is received within this period while the process is running, the run is marked as an infrastructure error." + }, + "renpy.testing.showWindow": { + "type": "boolean", + "default": false, + "description": "Shows the game window during test runs. If false, the window will be hidden to allow tests to run without user interaction." } } } diff --git a/src/extension.ts b/src/extension.ts index feef0a46..1c156433 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,7 @@ import { import { cleanUpPath, getAudioFolder, getImagesFolder, getNavigationJsonFilepath, getWorkspaceFolder, stripWorkspaceFromFile } from "src/utilities"; +import { registerTestingController } from "./testing/controller"; import { registerDebugDecorator, unregisterDebugDecorator } from "./tokenizer/debug-decorator"; import { Tokenizer } from "./tokenizer/tokenizer"; import { registerColorProvider } from "./color"; @@ -38,6 +39,7 @@ import { initializeLoggingSystems, logMessage, logToast, updateStatusBar } from import { getStatusBarText, NavigationData } from "./navigation-data"; import { registerSymbolProvider } from "./outline"; import { registerReferencesProvider } from "./references"; +import { buildRenpySpawnConfiguration, isValidExecutable } from "./renpy-command"; import { registerSemanticTokensProvider } from "./semantics"; import { registerSignatureProvider } from "./signature"; import { RenpyTaskProvider } from "./task-provider"; @@ -298,6 +300,9 @@ export async function activate(context: ExtensionContext): Promise { const taskProvider = new RenpyTaskProvider(); context.subscriptions.push(tasks.registerTaskProvider("renpy", taskProvider)); + // Register Ren'Py Test Explorer controller. + registerTestingController(context); + logMessage(LogLevel.Info, "Ren'Py extension activated!"); } @@ -332,12 +337,6 @@ export function getKeywordPrefix(document: TextDocument, position: Position, ran return; } -export function isValidExecutable(renpyExecutableLocation: string): boolean { - if (!renpyExecutableLocation || renpyExecutableLocation === "") { - return false; - } - return fs.existsSync(renpyExecutableLocation); -} // Attempts to run renpy executable through console commands. export function RunWorkspaceFolder(): boolean { const childProcess = ExecuteRunpyRun(); @@ -368,38 +367,37 @@ export function RunWorkspaceFolder(): boolean { export function ExecuteRunpyRun(): cp.ChildProcessWithoutNullStreams | null { const rpyPath = Configuration.getRenpyExecutablePath(); - if (!isValidExecutable(rpyPath)) { + const workFolder = getWorkspaceFolder(); + const spawnConfig = buildRenpySpawnConfiguration(rpyPath, [`${workFolder}`, "run"]); + if (spawnConfig == null || !isValidExecutable(rpyPath)) { return null; } - const renpyPath = cleanUpPath(Uri.file(rpyPath).path); - const cwd = renpyPath.substring(0, renpyPath.lastIndexOf("/")); - const workFolder = getWorkspaceFolder(); - const args: string[] = [`${workFolder}`, "run"]; - return cp.spawn(rpyPath, args, { - cwd: `${cwd}`, + return cp.spawn(spawnConfig.command, spawnConfig.args, { + cwd: spawnConfig.cwd, env: { PATH: process.env.PATH }, }); } -function ExecuteRenpyCompile(): boolean { - const rpyPath = Configuration.getRenpyExecutablePath(); +export function ExecuteRenpyCompile(rpyPath: string = ""): boolean { + if (!rpyPath) { + rpyPath = Configuration.getRenpyExecutablePath(); + } if (isValidExecutable(rpyPath)) { - const renpyPath = cleanUpPath(Uri.file(rpyPath).path); - const cwd = renpyPath.substring(0, renpyPath.lastIndexOf("/")); - let wf = getWorkspaceFolder(); if (wf.endsWith("/game")) { wf = wf.substring(0, wf.length - 5); } const navData = getNavigationJsonFilepath(); - //const args = `${wf} compile --json-dump ${navData}`; - const args: string[] = [`${wf}`, "compile", "--json-dump", `${navData}`]; + const spawnConfig = buildRenpySpawnConfiguration(rpyPath, [`${wf}`, "compile", "--json-dump", `${navData}`]); + if (spawnConfig == null) { + return false; + } try { NavigationData.isCompiling = true; updateStatusBar("$(sync~spin) Compiling Ren'Py navigation data..."); - const result = cp.spawnSync(rpyPath, args, { - cwd: `${cwd}`, + const result = cp.spawnSync(spawnConfig.command, spawnConfig.args, { + cwd: spawnConfig.cwd, env: { PATH: process.env.PATH }, encoding: "utf-8", windowsHide: true, diff --git a/src/renpy-command.ts b/src/renpy-command.ts new file mode 100644 index 00000000..ee89dae2 --- /dev/null +++ b/src/renpy-command.ts @@ -0,0 +1,113 @@ +import * as fs from "fs"; +import * as path from "path"; + +const MAX_PARENT_LOOKUP = 6; + +export interface RenpySpawnConfiguration { + command: string; + args: string[]; + cwd?: string; +} + +function splitCommandLine(commandLine: string): string[] { + const tokens: string[] = []; + let current = ""; + let quoteChar: string | null = null; + + for (let i = 0; i < commandLine.length; i++) { + const char = commandLine[i]; + + if ((char === '"' || char === "'") && quoteChar == null) { + quoteChar = char; + continue; + } + + if (char === quoteChar) { + quoteChar = null; + continue; + } + + if (/\s/.test(char) && quoteChar == null) { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + continue; + } + + current += char; + } + + if (current.length > 0) { + tokens.push(current); + } + + return tokens; +} + +function isPathLike(command: string): boolean { + return path.isAbsolute(command) || command.includes("/") || command.includes("\\"); +} + +function resolveWorkingDirectory(command: string, preArgs: string[]): string | undefined { + if (!path.isAbsolute(command)) { + return undefined; + } + + const commandDir = path.dirname(command); + const firstArg = preArgs[0]; + if (!firstArg || path.isAbsolute(firstArg) || !firstArg.toLowerCase().endsWith(".py")) { + return commandDir; + } + + let current = commandDir; + for (let i = 0; i < MAX_PARENT_LOOKUP; ++i) { + if (fs.existsSync(path.join(current, firstArg))) { + return current; + } + + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return commandDir; +} + +export function buildRenpySpawnConfiguration(executableOverride: string, args: string[]): RenpySpawnConfiguration | null { + const tokens = splitCommandLine(executableOverride.trim()); + if (tokens.length === 0) { + return null; + } + + const command = tokens[0]; + const preArgs = tokens.slice(1); + const cwd = resolveWorkingDirectory(command, preArgs); + + const config: RenpySpawnConfiguration = { + command, + args: [...preArgs, ...args], + }; + + if (cwd !== undefined) { + config.cwd = cwd; + } + + return config; +} + +export function isValidExecutable(executableOverride: string): boolean { + const spawnConfig = buildRenpySpawnConfiguration(executableOverride, []); + if (spawnConfig == null || spawnConfig.command.length === 0) { + return false; + } + + if (!isPathLike(spawnConfig.command)) { + // Allow commands resolved by PATH, like "python". + return true; + } + + return fs.existsSync(spawnConfig.command); +} diff --git a/src/task-provider.ts b/src/task-provider.ts index 8c090eb7..bc21a51d 100644 --- a/src/task-provider.ts +++ b/src/task-provider.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { getWorkspaceFolder } from "src/utilities"; import { Configuration } from "./configuration"; +import { buildRenpySpawnConfiguration } from "./renpy-command"; interface RenpyTaskDefinition extends vscode.TaskDefinition { command: string; @@ -44,12 +45,17 @@ export class RenpyTaskProvider implements vscode.TaskProvider { args.push(...definition.args); } + const spawnConfig = buildRenpySpawnConfiguration(Configuration.getRenpyExecutablePath(), args); + const command = spawnConfig?.command ?? Configuration.getRenpyExecutablePath(); + const finalArgs = spawnConfig?.args ?? args; + const options = spawnConfig?.cwd != null ? { cwd: spawnConfig.cwd } : undefined; + return new vscode.Task( definition, vscode.TaskScope.Workspace, definition.command, "renpy", - new vscode.ShellExecution(Configuration.getRenpyExecutablePath(), args) + new vscode.ShellExecution(command, finalArgs, options) ); } } diff --git a/src/testing/controller.ts b/src/testing/controller.ts new file mode 100644 index 00000000..fef7bb77 --- /dev/null +++ b/src/testing/controller.ts @@ -0,0 +1,177 @@ +import * as crypto from "crypto"; +import * as path from "path"; +import * as vscode from "vscode"; + +import { getWorkspaceFolder } from "src/utilities"; + +import { logMessage } from "../logger"; + +import { discoverTests } from "./discovery"; +import { executeTestRun } from "./run-executor"; +import { getTestingSettings } from "./settings"; + +/** + * Registers the Ren'Py Test Explorer controller with VS Code. + * + * Creates a TestController, a Run profile, and wires the refresh/resolve + * handlers through the extension context lifecycle. + */ +export function registerTestingController(context: vscode.ExtensionContext): void { + const controller = vscode.tests.createTestController("renpy-tests", "Ren'Py Tests"); + context.subscriptions.push(controller); + + const idMap = new Map(); + let activeRunCount = 0; + + // == Shared discovery helper =============================================== + async function runDiscovery(forceRefresh: boolean): Promise { + if (activeRunCount > 0) { + logMessage(vscode.LogLevel.Info, "[renpy-vscode] Discovery skipped: a test run is in progress."); + return; + } + void vscode.commands.executeCommand("setContext", "renpy.testExplorerState", "loading"); + const settings = getTestingSettings(); + const result = await discoverTests(controller, idMap, settings, forceRefresh); + const nextState = result.testCount > 0 ? undefined : "empty"; + if (!result.success && result.error) { + logMessage(vscode.LogLevel.Warning, `[renpy-vscode] Discovery failed: ${result.error}`); + } + void vscode.commands.executeCommand("setContext", "renpy.testExplorerState", nextState); + } + + // == Proactive initial discovery =========================================== + // VS Code calls resolveHandler lazily (only after the Test Explorer is opened + // and the user interacts with it). To ensure tests appear immediately without + // user interaction, we also trigger discovery eagerly on activation. + void runDiscovery(false).catch((err) => { + logMessage(vscode.LogLevel.Error, `[renpy-vscode] Initial discovery threw unexpectedly: ${err}`); + }); + + // == Refresh handler ======================================================= + // Triggered when the user clicks the refresh button in Test Explorer. + controller.refreshHandler = async (token: vscode.CancellationToken) => { + if (token.isCancellationRequested) return; + await runDiscovery(true); + }; + + // == Resolve handler ======================================================= + // VS Code may call this for initial load or when expanding items. We still + // handle it so that VS Code-triggered refreshes (e.g. after file changes) + // also populate the tree. + controller.resolveHandler = async (item: vscode.TestItem | undefined) => { + if (item === undefined) { + await runDiscovery(false); + } + // For non-root items: discovery is done eagerly so no lazy loading needed. + }; + + // == Run profile =========================================================== + const runProfile = controller.createRunProfile( + "Run Tests", + vscode.TestRunProfileKind.Run, + async (request: vscode.TestRunRequest, token: vscode.CancellationToken) => { + const settings = getTestingSettings(); + const testRun = controller.createTestRun(request); + + const targets = collectLeafItems(request, controller); + + // Mark all targets as enqueued for responsive UI. + for (const item of targets) { + testRun.enqueued(item); + } + + const runId = crypto.randomUUID(); + const streamPath = resolveStreamPath(settings.jsonlOutputPath, runId); + const gameDir = resolveGameDir(); + + logMessage(vscode.LogLevel.Info, `[renpy-vscode] Run ${runId}: ${targets.length} item(s), stream: ${streamPath}`); + + activeRunCount++; + try { + await executeTestRun(testRun, idMap, { targets, includedItems: request.include, runId, streamPath, settings, gameDir }, token); + } catch (err) { + logMessage(vscode.LogLevel.Error, `[renpy-vscode] Run ${runId} threw unexpectedly: ${err}`); + const msg = new vscode.TestMessage(`Unexpected error during test run: ${err}`); + for (const item of targets) { + testRun.errored(item, msg); + } + } finally { + activeRunCount--; + testRun.end(); + } + }, + true, // isDefault + ); + context.subscriptions.push(runProfile); + + logMessage(vscode.LogLevel.Info, "[renpy-vscode] Test controller registered."); +} + +/** + * Collects all leaf TestItems (no children) from a run request. + * If request.include is undefined, walks all items in the controller. + */ +function collectLeafItems(request: vscode.TestRunRequest, controller: vscode.TestController): vscode.TestItem[] { + const result: vscode.TestItem[] = []; + const excluded = new Set(request.exclude ?? []); + + function walk(items: vscode.TestItemCollection): void { + items.forEach((item) => { + if (excluded.has(item)) return; + if (item.children.size === 0) { + result.push(item); + } else { + walk(item.children); + } + }); + } + + if (request.include !== undefined) { + for (const item of request.include) { + if (excluded.has(item)) continue; + if (item.children.size === 0) { + result.push(item); + } else { + walk(item.children); + } + } + } else { + walk(controller.items); + } + + return result; +} + +/** + * Resolves the output path for a per-run JSONL file. + * Falls back to the workspace saves/ folder, then the OS temp directory. + */ +function resolveStreamPath(configuredPath: string, runId: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `rpytest-${timestamp}_${runId.slice(0, 8)}.jsonl`; + + if (configuredPath.trim() !== "") { + return path.join(configuredPath, filename); + } + + // Fallback to OS temp directory. + return path.join(getTempDir(), filename); +} + +/** + * Resolves the Ren'Py game/project directory from the workspace folder. + * Uses the same path normalization as ExecuteRenpyRun / ExecuteRenpyCompile. + */ +function resolveGameDir(): string { + let wf = getWorkspaceFolder(); + if (wf.endsWith("/game")) { + wf = wf.substring(0, wf.length - 5); + } + return wf; +} + +function getTempDir(): string { + // Use process.env.TEMP/TMP on Windows, /tmp on Unix. + const dir = process.env["TEMP"] ?? process.env["TMP"] ?? "/tmp"; + return path.join(dir, "renpy-vscode"); +} diff --git a/src/testing/discovery.ts b/src/testing/discovery.ts new file mode 100644 index 00000000..ffbc5983 --- /dev/null +++ b/src/testing/discovery.ts @@ -0,0 +1,300 @@ +import * as fs from "fs"; +import * as vscode from "vscode"; + +import { ExecuteRenpyCompile } from "src/extension"; +import { getFileWithPath, getNavigationJsonFilepath } from "src/utilities"; + +import { logMessage } from "../logger"; + +import type { TestingSettings } from "./settings"; + +/** Result of a discovery refresh. */ +export interface DiscoveryResult { + success: boolean; + error?: string; + testCount: number; +} + +// == Navigation.json test section types ======================================= + +interface NavParamInstance { + index: number; + display_name: string; +} + +interface NavTestItem { + full_path: string; + name: string; + kind: "testsuite" | "testcase" | "hook"; + file: string; + line: number; + parent_full_path: string | null; + parameterized: NavParamInstance[]; + description: string; +} + +interface NavTestSection { + items?: unknown[]; +} + +interface LoadNavigationJsonResult { + navData?: Record; + error?: string; +} + +// == Schema validation ========================================================= + +async function compileAndLoadNavigationJson( + rpyPath: string, + shouldCompile: boolean, +): Promise { + const navPath = getNavigationJsonFilepath(); + + if (shouldCompile) { + const compileSuccess = await ExecuteRenpyCompile(rpyPath); + if (!compileSuccess) { + return { + error: `Compile failed. Make sure the game can be compiled successfully with the configured Ren'Py executable ('${rpyPath}').`, + }; + } + } + + if (!fs.existsSync(navPath)) { + return { + error: `navigation.json not found at '${navPath}'. Run 'Renpy: Compile Ren'Py Navigation Data' first.`, + }; + } + + try { + const raw = fs.readFileSync(navPath, "utf-8"); + return { navData: JSON.parse(raw) as Record }; + } catch (err) { + return { + error: `Failed to read or parse navigation.json: ${err}`, + }; + } +} + +// == Tree building ============================================================= + +function isValidNavTestItem(obj: unknown): obj is NavTestItem { + if (typeof obj !== "object" || obj === null) return false; + const r = obj as Record; + return ( + typeof r["full_path"] === "string" && + typeof r["name"] === "string" && + typeof r["kind"] === "string" && + typeof r["file"] === "string" && + typeof r["line"] === "number" && + Array.isArray(r["parameterized"]) + ); +} + +/** + * Resolves a navigation.json file path (e.g. `game/tests/global.rpy`) to an + * absolute filesystem path using the existing getFileWithPath utility. + */ +function resolveNavFile(navFile: string): vscode.Uri { + const abs = getFileWithPath(navFile); + return vscode.Uri.file(abs); +} + +/** + * Tracks a single "slot" in the test tree where a node has been placed. + * + * Non-parameterized items have one ChildContext per copy (there may be multiple + * copies when the parent is parameterized). Parameterized items have one + * ChildContext per *parameter instance* (and again possibly multiplied by the + * number of parent copies). + * + * `effectiveRenpyPath` is the stable renpy-facing path for this node within + * its parameter context, e.g. `"global.chapters(tip_state=0).save"`. + */ +interface ChildContext { + parentCollection: vscode.TestItemCollection; + effectiveRenpyPath: string; +} + +function buildTree( + controller: vscode.TestController, + idMap: Map, + items: NavTestItem[], +): number { + // Maps full_path → all instances of this node currently in the tree. + // When a parent is parameterized the same full_path can appear once per + // parent parameter instance, each with its own ChildContext. + const containerMap = new Map(); + let count = 0; + + for (const item of items) { + // v1: skip hooks entirely. + if (item.kind === "hook") continue; + + const uri = resolveNavFile(item.file); + const range = new vscode.Range(item.line - 1, 0, item.line - 1, 0); + + // == Resolve parent contexts ========================================== + // For root items (parent_full_path === null) there is a single context + // rooted at the controller's top-level collection. For everything else + // look up the already-registered instances of the parent. + let parentContexts: ChildContext[]; + if (item.parent_full_path === null) { + parentContexts = [{ parentCollection: controller.items, effectiveRenpyPath: "" }]; + } else { + const parentEntries = containerMap.get(item.parent_full_path); + parentContexts = parentEntries && parentEntries.length > 0 + ? parentEntries + : [{ parentCollection: controller.items, effectiveRenpyPath: "" }]; + } + + // The part of full_path that is unique to this item (i.e. the + // separator + name appended to the parent's full_path). For root + // items this is the complete full_path. + const relativeSuffix = item.parent_full_path !== null + ? item.full_path.substring(item.parent_full_path.length) + : item.full_path; + + // The separator/prefix portion of relativeSuffix without the bare item.name + // at the end (e.g. "global." for root items, "." for child items). + // Used to splice in the display_name when building effectiveDisplayPath for + // parameterized instances. + const relativeSuffixPrefix = relativeSuffix.substring(0, relativeSuffix.length - item.name.length); + + const paramCount = item.parameterized.length; + + // == Contexts that children of *this* item will use =================== + const myContexts: ChildContext[] = []; + + for (const parentContext of parentContexts) { + // Stable Ren'Py path for this node within this particular parent context. + const myEffectiveRenpyPath = parentContext.effectiveRenpyPath + relativeSuffix; + + if (paramCount <= 1) { + // Non-parameterized (or single-instance): one TestItem per parent context. + const testItem = controller.createTestItem(myEffectiveRenpyPath, item.name, uri); + testItem.range = range; + testItem.description = item.description; + parentContext.parentCollection.add(testItem); + idMap.set(testItem.id, testItem); + count++; + + myContexts.push({ parentCollection: testItem.children, effectiveRenpyPath: myEffectiveRenpyPath }); + } else { + // Multi-param: one group container + one TestItem per instance, + // all created under this particular parent context. + const groupItem = controller.createTestItem(myEffectiveRenpyPath, item.name, uri); + groupItem.range = range; + groupItem.description = item.description; + parentContext.parentCollection.add(groupItem); + + for (const param of item.parameterized) { + const instanceEffectiveRenpyPath = parentContext.effectiveRenpyPath + relativeSuffixPrefix + param.display_name; + const instanceItem = controller.createTestItem(instanceEffectiveRenpyPath, param.display_name, uri); + instanceItem.range = range; + groupItem.children.add(instanceItem); + idMap.set(instanceItem.id, instanceItem); + count++; + + myContexts.push({ parentCollection: instanceItem.children, effectiveRenpyPath: instanceEffectiveRenpyPath }); + } + } + } + + containerMap.set(item.full_path, myContexts); + } + + return count; +} + +// == Main entry point ========================================================== + +/** + * Loads test metadata from navigation.json and builds the VS Code TestItem tree. + * + * Refresh pipeline: + * - If `forceRefresh` or mode=always-refresh: generate new navigation.json first. + * - Otherwise (cache-first): use the existing navigation.json if present. + */ +export async function discoverTests( + controller: vscode.TestController, + idMap: Map, + settings: TestingSettings, + forceRefresh: boolean, +): Promise { + logMessage(vscode.LogLevel.Info, `[renpy-vscode] Test Discovery: starting (forceRefresh=${forceRefresh}, mode=${settings.discoveryRefreshMode})`); + + const shouldCompile = forceRefresh || settings.discoveryRefreshMode === "always-refresh"; + + const initialLoad = await compileAndLoadNavigationJson(settings.renpyExecutablePath, shouldCompile); + if (initialLoad.error) { + logMessage(vscode.LogLevel.Warning, `[renpy-vscode] Test Discovery: ${initialLoad.error}`); + surfaceDiscoveryError(controller, initialLoad.error); + return { success: false, error: initialLoad.error, testCount: 0 }; + } + + let navData = initialLoad.navData ?? {}; + + // If navigation.json has no test section, it was compiled without test metadata + // (e.g. by an older version of Ren'Py). Trigger a silent recompile to get it, + // but only when we haven't already compiled this pass. + if (!("test" in navData) && !shouldCompile) { + logMessage(vscode.LogLevel.Info, "[renpy-vscode] Test Discovery: navigation.json has no test section; recompiling to get test metadata."); + const reload = await compileAndLoadNavigationJson(settings.renpyExecutablePath, true); + if (reload.navData) { + navData = reload.navData; + } else if (reload.error) { + logMessage(vscode.LogLevel.Warning, `[renpy-vscode] Test Discovery: auto-recompile for test metadata failed`); + } + } + + const navTest = (navData["test"] ?? {}) as NavTestSection; + + // Validate the test section schema before doing any tree manipulation. + if (!navTest.items || navTest.items.length === 0) { + resetDiscoveryState(controller, idMap); + if (!("test" in navData)) { + logMessage(vscode.LogLevel.Info, "[renpy-vscode] Test Discovery: no test section in navigation.json."); + } else { + logMessage(vscode.LogLevel.Info, "[renpy-vscode] Test Discovery: no test items found in navigation.json."); + } + surfaceNoTestsFound(controller); + return { success: true, testCount: 0 }; + } + + // Validate and collect items. + const validItems: NavTestItem[] = []; + for (const raw of navTest.items) { + if (isValidNavTestItem(raw)) { + validItems.push(raw); + } else { + logMessage(vscode.LogLevel.Warning, `[renpy-vscode] Test Discovery: skipping malformed item: ${JSON.stringify(raw).slice(0, 200)}`); + } + } + + // Clear existing tree and mappings before rebuilding. + resetDiscoveryState(controller, idMap); + + // Build the TestItem tree. + const testCount = buildTree(controller, idMap, validItems); + + logMessage(vscode.LogLevel.Info, `[renpy-vscode] Test Discovery: Complete. ${testCount} runnable item(s) in tree.`); + return { success: true, testCount }; +} + +function resetDiscoveryState(controller: vscode.TestController, idMap: Map): void { + controller.items.replace([]); + idMap.clear(); +} + +function surfaceNoTestsFound(controller: vscode.TestController): void { + const item = controller.createTestItem("renpy-no-tests", "No Ren'Py tests found"); + item.description = "Learn more\u2026"; + item.error = new vscode.MarkdownString("[Learn more about Ren'Py testing](https://www.renpy.org/doc/html/testcases.html)"); + controller.items.replace([item]); +} + +function surfaceDiscoveryError(controller: vscode.TestController, message: string): void { + const errorItem = controller.createTestItem("renpy-discovery-error", "Test discovery failed, see output"); + errorItem.error = message; + controller.items.replace([errorItem]); +} diff --git a/src/testing/event-mapper.ts b/src/testing/event-mapper.ts new file mode 100644 index 00000000..ef068e4c --- /dev/null +++ b/src/testing/event-mapper.ts @@ -0,0 +1,132 @@ +import * as vscode from "vscode"; + +import { type AnyEvent, isTerminalEvent, isTestEndEvent, type OnTestEndEvent, type RenpyOutcomeStatus, type VsCodeOutcomeStatus } from "./event-schema"; + +interface OutcomeMappingResult { + status: VsCodeOutcomeStatus; + note?: string; +} + +/** + * Maps Ren'Py outcome semantics to VS Code test statuses. + */ +function mapRenpyOutcomeToVsCode(status: RenpyOutcomeStatus): OutcomeMappingResult { + switch (status) { + case "passed": + return { status: "passed" }; + case "failed": + return { status: "failed" }; + case "skipped": + return { status: "skipped" }; + case "xfailed": + return { + status: "passed", + note: "Expected failure (xfailed).", + }; + case "xpassed": + return { + status: "failed", + note: "Unexpected pass (xpassed).", + }; + default: + return { + status: "errored", + note: `Unknown renpy outcome status: ${status}`, + }; + } +} + +/** + * Mutable state tracked per run by the event mapper. + * Sequence monotonicity and on_run_end receipt are validated here. + */ +export interface MapperState { + runId: string; + /** Last consumed sequence number; -1 before any event. */ + lastSequence: number; + hasReceivedOnEnd: boolean; + terminalStatus?: RenpyOutcomeStatus; +} + +export function createMapperState(runId: string): MapperState { + return { runId, lastSequence: -1, hasReceivedOnEnd: false }; +} + +/** + * Applies a single validated JSONL event to the active TestRun. + * + * Returns an infrastructure error string if the event violates run invariants; + * returns undefined on success. + * + * Full dispatch implementation: Phase 5. + */ +export function applyEvent(event: AnyEvent, state: MapperState, testRun: vscode.TestRun, idMap: Map): string | undefined { + // --- Invariant: sequence must be strictly increasing --- + if (event.sequence <= state.lastSequence) { + return ( + `Sequence violation for run '${state.runId}': ` + + `expected sequence > ${state.lastSequence}, got ${event.sequence} (event: ${event.event}).` + ); + } + state.lastSequence = event.sequence; + + // --- Invariant: run_id must match --- + if (event.run_id !== state.runId) { + return `run_id mismatch: expected '${state.runId}', got '${event.run_id}' at sequence ${event.sequence}.`; + } + + if (isTestEndEvent(event)) { + applyTestEnd(event, testRun, idMap); + } else if (isTerminalEvent(event)) { + state.hasReceivedOnEnd = true; + state.terminalStatus = event.final_status; + } + + return undefined; +} + +function applyTestEnd(event: OnTestEndEvent, testRun: vscode.TestRun, idMap: Map): void { + const item = idMap.get(event.test_id); + if (!item) { + // This sometimes happens for test hooks + // testRun.appendOutput(`[renpy-vscode] Warning: received result for unknown test_id '${event.test_id}'\r\n`); + return; + } + + const vsCodeOutcome = mapRenpyOutcomeToVsCode(event.status); + const note = vsCodeOutcome.note ?? ""; + let fullMessage = ""; + if (event.error) { + fullMessage += event.error; + } + if (event.traceback) { + fullMessage += `\n\n${event.traceback}`; + } + if (note) { + fullMessage += (fullMessage ? "\n\n" : "") + note; + } + fullMessage = fullMessage.trim(); + const durationMs = event.duration_seconds !== undefined ? event.duration_seconds * 1000 : undefined; + + switch (vsCodeOutcome.status) { + case "passed": + testRun.passed(item, durationMs); + if (fullMessage) { + testRun.appendOutput(`${fullMessage.replace(/(? = ["run_id", "sequence", "timestamp", "event"]; + +/** Validates that an unknown value satisfies the base event contract. */ +export function validateBaseEvent(obj: unknown): ValidationResult { + if (typeof obj !== "object" || obj === null) { + return { valid: false, error: "Event is not an object." }; + } + const record = obj as Record; + + for (const field of REQUIRED_BASE_FIELDS) { + if (!(field in record)) { + return { valid: false, error: `Missing required field: ${field}` }; + } + } + + if (typeof record["run_id"] !== "string" || record["run_id"] === "") { + return { valid: false, error: "run_id must be a non-empty string." }; + } + if (typeof record["sequence"] !== "number" || !Number.isInteger(record["sequence"]) || record["sequence"] < 0) { + return { valid: false, error: "sequence must be a non-negative integer." }; + } + if (typeof record["event"] !== "string" || record["event"] === "") { + return { valid: false, error: "event must be a non-empty string." }; + } + if (typeof record["timestamp"] !== "number" || !Number.isFinite(record["timestamp"])) { + return { valid: false, error: "timestamp must be a valid number." }; + } + return { valid: true }; +} + +export function isTerminalEvent(event: AnyEvent): event is OnRunEndEvent { + return event.event === "on_run_end"; +} + +export function isTestEndEvent(event: AnyEvent): event is OnTestEndEvent { + return event.event === "on_test_end"; +} + +/** Parses a single JSONL line. Returns the event or an error string. */ +export function parseJsonlLine(line: string): { event: AnyEvent } | { error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return { error: `Failed to parse JSON: ${line.slice(0, 120)}` }; + } + const result = validateBaseEvent(parsed); + if (!result.valid) { + return { error: result.error ?? "Unknown validation error." }; + } + return { event: parsed as AnyEvent }; +} diff --git a/src/testing/id-map.ts b/src/testing/id-map.ts new file mode 100644 index 00000000..a521981f --- /dev/null +++ b/src/testing/id-map.ts @@ -0,0 +1,21 @@ +import type * as vscode from "vscode"; + +/** + * Mapping between Ren'Py test IDs and TestItem instances. + * Cleared and rebuilt on each discovery refresh. + */ +export class IdMap { + private readonly idToItem = new Map(); + + register(item: vscode.TestItem): void { + this.idToItem.set(item.id, item); + } + + get(id: string): vscode.TestItem | undefined { + return this.idToItem.get(id); + } + + clear(): void { + this.idToItem.clear(); + } +} diff --git a/src/testing/run-executor.ts b/src/testing/run-executor.ts new file mode 100644 index 00000000..8b1d4c55 --- /dev/null +++ b/src/testing/run-executor.ts @@ -0,0 +1,291 @@ +import * as cp from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; + +import { logMessage } from "../logger"; +import { buildRenpySpawnConfiguration, isValidExecutable } from "../renpy-command"; + +import { applyEvent, createMapperState } from "./event-mapper"; +import { isTestEndEvent } from "./event-schema"; +import type { TestingSettings } from "./settings"; +import { StreamPoller } from "./stream-poller"; + +export interface RunOptions { + /** Resolved leaf test items selected for this run (for status reporting). */ + targets: vscode.TestItem[]; + /** Original request.include items (for Ren'Py target resolution). Undefined means run all. */ + includedItems: readonly vscode.TestItem[] | undefined; + /** Unique identifier for this run, matches run_id in JSONL events. */ + runId: string; + /** Absolute path to the JSONL output file. */ + streamPath: string; + settings: TestingSettings; + /** Normalized workspace folder path (forward slashes, no trailing slash). */ + gameDir: string; +} + +/** + * Ren'Py test command expects testcase ids without the leading "global." prefix. + */ +function normalizeRenpyTarget(fullPath: string): string { + return fullPath.startsWith("global.") ? fullPath.substring("global.".length) : fullPath; +} + +/** + * Resolves the most specific common Ren'Py testcase / testsuite target from the + * included items. Returns "global" when all tests should run. + * + * Examples: + * [global.foo.bar, global.foo.baz] → "global.foo" + * [global.foo] → "global.foo" + * undefined → "global" + */ +function resolveRenpyTarget(includedItems: readonly vscode.TestItem[] | undefined): string { + const DEFAULT = "global"; + if (includedItems === undefined || includedItems.length === 0) return DEFAULT; + + const fullPaths: string[] = []; + for (const item of includedItems) { + fullPaths.push(normalizeRenpyTarget(item.id)); + } + + if (fullPaths.length === 0) return DEFAULT; + + const unique = [...new Set(fullPaths)]; + if (unique.length === 1) return unique[0]; + + // Find the longest common dot-separated prefix across all full_paths. + const segmented = unique.map((p) => p.split(".")); + const shortest = segmented.reduce((min, s) => (s.length < min.length ? s : min)); + const common: string[] = []; + for (let i = 0; i < shortest.length; i++) { + const seg = shortest[i]; + if (segmented.every((s) => s[i] === seg)) { + common.push(seg); + } else { + break; + } + } + return common.length > 0 ? common.join(".") : DEFAULT; +} + +/** Returns a promise that resolves with the process exit code when the process closes. */ +function waitForProcessClose(proc: cp.ChildProcess): Promise { + return new Promise((resolve) => { + proc.on("close", (code) => resolve(code)); + proc.on("error", () => resolve(null)); + }); +} + +function createMissingExecutableMessage(renpyExecutablePath: string): vscode.TestMessage { + return new vscode.TestMessage( + `Ren'Py executable not configured or not found: '${renpyExecutablePath}'. ` + + `Set 'renpy.renpyExecutableLocation' or 'renpy.testing.renpyExecutableOverride' in settings.`, + ); +} + +function markItemsErrored(items: Iterable, testRun: vscode.TestRun, message: vscode.TestMessage): void { + for (const item of items) { + testRun.errored(item, message); + } +} + +function markItemsSkipped(items: Iterable, testRun: vscode.TestRun): void { + for (const item of items) { + testRun.skipped(item); + } +} + +function markItemsPassedWithNote(items: Set, testRun: vscode.TestRun, note: (item: vscode.TestItem) => string): void { + for (const item of items) { + testRun.passed(item); + testRun.appendOutput(`${note(item).replace(/(?, options: RunOptions, token: vscode.CancellationToken): Promise { + if (!isValidExecutable(options.settings.renpyExecutablePath)) { + const msg = createMissingExecutableMessage(options.settings.renpyExecutablePath); + markItemsErrored(options.targets, testRun, msg); + return; + } + + if (options.gameDir === "") { + const msg = new vscode.TestMessage("No workspace folder is open. Cannot run Ren'Py tests."); + markItemsErrored(options.targets, testRun, msg); + return; + } + + // Ensure the stream output directory exists. + try { + const dir = path.dirname(options.streamPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } catch (err) { + const msg = new vscode.TestMessage(`Failed to create JSONL output directory: ${err}`); + markItemsErrored(options.targets, testRun, msg); + return; + } + + const targetArg = resolveRenpyTarget(options.includedItems); + + // Ensure the JSONL reporter is included for Test Explorer integration + const reporters = options.settings.reporters.split(",").map((s) => s.trim()); + if (reporters.indexOf("jsonl") < 0) { + reporters.push("jsonl"); + } + + const runArgs: string[] = [ + options.gameDir, + "test", + targetArg, + "--reporter", + reporters.join(","), + "--jsonl-output", + options.streamPath.replace(/\\/g, "/"), + "--jsonl-run-id", + options.runId, + "--jsonl-batch-size", + String(options.settings.jsonlBatchSize), + "--jsonl-flush-interval-ms", + String(options.settings.jsonlFlushInterval), + ]; + + const spawnConfig = buildRenpySpawnConfiguration(options.settings.renpyExecutablePath, runArgs); + if (spawnConfig == null) { + const msg = createMissingExecutableMessage(options.settings.renpyExecutablePath); + markItemsErrored(options.targets, testRun, msg); + return; + } + + // Log the full command line for debugging purposes. Escape arguments as needed. + const runArgsEscaped = ["cd", spawnConfig.cwd, "&&", spawnConfig.command, ...spawnConfig.args]; + for (let i = 0; i < runArgs.length; i++) { + const arg = runArgsEscaped[i]; + if (arg !== undefined && /[ \t\n\r\v"'`$\\]/.test(arg)) { + runArgsEscaped[i] = `"${arg.replace(/(["\\$`])/g, "\\$1")}"`; + } + } + const logCommand = runArgsEscaped.join(" "); + logMessage(vscode.LogLevel.Info, `[renpy-vscode] ${logCommand}`); + testRun.appendOutput(`[renpy-vscode] Run: ${logCommand}\r\n\r\n`); + + // Spawn the process + let proc: cp.ChildProcess; + try { + proc = cp.spawn(spawnConfig.command, spawnConfig.args, { + cwd: spawnConfig.cwd, + env: { PATH: process.env["PATH"] ?? "" }, + windowsHide: !options.settings.showWindow, + }); + } catch (err) { + const msg = new vscode.TestMessage(`Failed to spawn Ren'Py process: ${err}`); + markItemsErrored(options.targets, testRun, msg); + return; + } + + // Pipe process stdout/stderr to the test run output panel. + proc.stdout?.on("data", (chunk: Buffer) => { + testRun.appendOutput(chunk.toString().replace(/(? { + testRun.appendOutput(`[stderr] ${chunk.toString().replace(/(? { + const err = applyEvent(event, mapperState, testRun, idMap); + if (err) { + infraError = infraError ?? err; + poller.stop(); + return; + } + if (isTestEndEvent(event)) { + receivedTestEndCount++; + const item = idMap.get(event.test_id); + if (item) { + pendingItems.delete(item); + } + } + }, + (errorMsg) => { + infraError = infraError ?? errorMsg; + // The poller stops itself after firing an infrastructure error. + }, + token, + ); + + // Kill the process and stop polling on cancellation. + const cancelDisposable = token.onCancellationRequested(() => { + try { + proc.kill(); + } catch { + // ignore + } + poller.stop(); + }); + + // Start the polling loop; the returned promise resolves when polling is done. + const pollDonePromise = poller.start(); + + // Wait for the process to exit. + const exitCode = await waitForProcessClose(proc); + cancelDisposable.dispose(); + + logMessage(vscode.LogLevel.Info, `[renpy-vscode] Run ${options.runId}: process exited with code ${exitCode}`); + + // Notify the poller so it enters grace-window mode. + poller.notifyProcessExited(); + + // Wait for the poller to finish (on_run_end received, or stopped). + await pollDonePromise; + + // Cancellation: mark remaining items as skipped (not errored, run was intentional). + if (token.isCancellationRequested) { + markItemsSkipped(pendingItems, testRun); + return; + } + + // Surface any infrastructure error to still-pending items. + if (infraError) { + testRun.appendOutput(`\r\n[renpy-vscode] Infrastructure error: ${infraError.replace(/(? 0) { + markItemsPassedWithNote( + pendingItems, + testRun, + () => "[renpy-vscode] Note: inferred pass from successful process exit (no JSONL events were received; check reporter setting).", + ); + } + + // Warn about items that unexpectedly received no result. + for (const item of pendingItems) { + testRun.appendOutput(`[renpy-vscode] Warning: no result received for '${item.label}'\r\n`, undefined, item); + } +} diff --git a/src/testing/settings.ts b/src/testing/settings.ts new file mode 100644 index 00000000..154b30b4 --- /dev/null +++ b/src/testing/settings.ts @@ -0,0 +1,49 @@ +import { workspace } from "vscode"; + +import { Configuration } from "../configuration"; + +export type DiscoveryRefreshMode = "always-refresh" | "cache-first"; + +export interface TestingSettings { + discoveryRefreshMode: DiscoveryRefreshMode; + jsonlBatchSize: number; + jsonlFlushInterval: number; + jsonlOutputPath: string; + jsonlPollInterval: number; + jsonlStalledTimeout: number; + renpyExecutablePath: string; + reporters: string; + showWindow: boolean; +} + +const DEFAULTS = { + discoveryRefreshMode: "cache-first" as DiscoveryRefreshMode, + jsonlBatchSize: 50, + jsonlFlushInterval: 1000, + jsonlOutputPath: "", + jsonlPollInterval: 500, + jsonlStalledTimeout: 5000, + renpyExecutablePath: "", + reporters: "console", + showWindow: false, +}; + +export function getTestingSettings(): TestingSettings { + const config = workspace.getConfiguration("renpy.testing"); + + // Executable: use testing override if set, else fall back to main setting. + const override = config.get("renpyExecutableOverride", ""); + const renpyExecutablePath = override.trim() !== "" ? override : Configuration.getRenpyExecutablePath(); + + return { + renpyExecutablePath, + discoveryRefreshMode: config.get("discoveryRefreshMode", DEFAULTS.discoveryRefreshMode), + jsonlBatchSize: config.get("jsonlBatchSize", DEFAULTS.jsonlBatchSize), + jsonlFlushInterval: config.get("jsonlFlushInterval", DEFAULTS.jsonlFlushInterval), + jsonlOutputPath: config.get("jsonlOutputPath", DEFAULTS.jsonlOutputPath), + jsonlPollInterval: config.get("jsonlPollInterval", DEFAULTS.jsonlPollInterval), + jsonlStalledTimeout: config.get("jsonlStalledTimeout", DEFAULTS.jsonlStalledTimeout), + reporters: config.get("reporters", DEFAULTS.reporters), + showWindow: config.get("showWindow", DEFAULTS.showWindow), + }; +} diff --git a/src/testing/stream-poller.ts b/src/testing/stream-poller.ts new file mode 100644 index 00000000..6abd986e --- /dev/null +++ b/src/testing/stream-poller.ts @@ -0,0 +1,189 @@ +import * as fs from "fs"; +import * as vscode from "vscode"; + +import { type AnyEvent, isTerminalEvent, parseJsonlLine } from "./event-schema"; + +export type EventCallback = (event: AnyEvent) => void; +export type InfrastructureErrorCallback = (error: string) => void; + +export interface PollerOptions { + /** Absolute path to the JSONL file produced by the reporter. */ + streamPath: string; + jsonlPollInterval: number; + /** Stalled-run detection: max ms without an event while process is alive. */ + jsonlStalledTimeout: number; + runId: string; +} + +/** + * Smart polling loop that reads the JSONL file using append-only delta reads. + * + * Algorithm: + * 1. stat() the file; skip read if size unchanged. + * 2. Read only the appended bytes from the last offset. + * 3. Split on newlines; hold trailing partial line in buffer. + * 4. Parse each complete line and fire onEvent / onInfrastructureError. + * 5. While process is alive, check stall timeout. + * 6. After notifyProcessExited(), fire infrastructure error if on_run_end was not received. + */ +export class StreamPoller { + private disposed = false; + private processExited = false; + private processExitedAt = 0; + private testStarted = false; + private lastEventTime = 0; + private offset = 0; + private lastStatSize = -1; + private carry = ""; + private pollTimer: ReturnType | undefined; + private readonly resolveCompletion: () => void; + private readonly completionPromise: Promise; + + constructor( + private readonly options: PollerOptions, + private readonly onEvent: EventCallback, + private readonly onInfrastructureError: InfrastructureErrorCallback, + private readonly token: vscode.CancellationToken, + ) { + let resolve!: () => void; + this.completionPromise = new Promise((r) => { + resolve = r; + }); + this.resolveCompletion = resolve; + } + + /** + * Starts the polling loop. + * Returns a Promise that resolves when polling is done (on_run_end received, + * stop() called, or cancellation). + */ + start(): Promise { + if (!this.disposed) { + this.lastEventTime = Date.now(); + this.schedulePoll(); + } + return this.completionPromise; + } + + /** + * Signals that the Ren'Py process has exited. + * Switches the poller into grace-window mode: polling continues for one more cycle. + */ + notifyProcessExited(): void { + if (!this.processExited) { + this.processExited = true; + this.processExitedAt = Date.now(); + } + } + + stop(): void { + if (!this.disposed) { + this.disposed = true; + if (this.pollTimer !== undefined) { + clearTimeout(this.pollTimer); + this.pollTimer = undefined; + } + this.resolveCompletion(); + } + } + + get isDisposed(): boolean { + return this.disposed; + } + + // == Private ============================================================== + + private schedulePoll(): void { + if (this.disposed) return; + this.pollTimer = setTimeout(() => this.poll(), this.options.jsonlPollInterval); + } + + private poll(): void { + if (this.disposed) return; + + if (this.token.isCancellationRequested) { + this.stop(); + return; + } + + const pollStartTime = Date.now(); + + // Read any newly appended bytes from the stream file. + try { + const stat = fs.statSync(this.options.streamPath); + if (stat.size > this.lastStatSize) { + this.lastStatSize = stat.size; + const toRead = stat.size - this.offset; + if (toRead > 0) { + const buf = Buffer.alloc(toRead); + const fd = fs.openSync(this.options.streamPath, "r"); + let bytesRead: number; + try { + bytesRead = fs.readSync(fd, buf, 0, toRead, this.offset); + } finally { + fs.closeSync(fd); + } + this.offset += bytesRead; + this.carry += buf.toString("utf8", 0, bytesRead); + + // Split on LF, tolerate CRLF line endings. + const lines = this.carry.split("\n"); + this.carry = lines.pop() ?? ""; // keep trailing partial line + + if (lines.length > 0) { + this.testStarted = true; + } + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, "").trim(); + if (line === "") continue; + + const result = parseJsonlLine(line); + if ("error" in result) { + this.onInfrastructureError(result.error); + this.stop(); + return; + } + + const event = result.event; + this.lastEventTime = event.timestamp; + + this.onEvent(event); + + if (this.disposed) return; // event handler may have called stop() + + if (isTerminalEvent(event)) { + this.stop(); + return; + } + } + } + } + } catch { + // File not yet created or transient I/O error, keep polling. + } + + // Stalled-run detection (only while the process is alive and after the first event). + if (this.testStarted && !this.processExited) { + const timeSinceLastEventMs = Date.now() - this.lastEventTime*1000; + if (timeSinceLastEventMs > this.options.jsonlStalledTimeout) { + this.onInfrastructureError( + `Stalled run '${this.options.runId}': no events for ${timeSinceLastEventMs} ms ` + + `(timeout: ${this.options.jsonlStalledTimeout} ms).`, + ); + this.stop(); + return; + } + } + + // Grace window: after process exits, poll one last time for buffered events + if (this.processExited) { + if (pollStartTime > this.processExitedAt) { + this.stop(); + return; + } + } + + this.schedulePoll(); + } +} diff --git a/syntaxes/renpy.test.tmLanguage.yaml b/syntaxes/renpy.test.tmLanguage.yaml index 940acfa7..cc922b3f 100644 --- a/syntaxes/renpy.test.tmLanguage.yaml +++ b/syntaxes/renpy.test.tmLanguage.yaml @@ -14,12 +14,14 @@ repository: - include: '#before-after-test' - name: keyword.control.test.renpy match: \b(?