diff --git a/scripts/e2e-multifile-build.test.mjs b/scripts/e2e-multifile-build.test.mjs index dc817e0..8c508ac 100644 --- a/scripts/e2e-multifile-build.test.mjs +++ b/scripts/e2e-multifile-build.test.mjs @@ -5,6 +5,7 @@ import { parseGxxArgs, resolveWorkspacePath, resolveRunTarget, + selectRunBinaryBytes, selectWorkspaceSources, buildCompileOverlay, isProjectSource, @@ -137,6 +138,33 @@ test('e2e: ./name before any successful build reports no binary', () => { assert.deepEqual(resolveRunTarget('./a.out', null), { ok: false, error: 'no-binary' }); }); +test('e2e: a restored workspace binary is runnable without build-session state', () => { + assert.deepEqual( + resolveRunTarget('build/app', null, ['a.out', 'build/app']), + { ok: true, path: 'build/app', source: 'workspace' } + ); + assert.deepEqual( + resolveRunTarget('missing', null, ['a.out', 'build/app']), + { ok: false, error: 'not-found' } + ); +}); + +test('e2e: explicit restored target bytes override a stale cached binary', () => { + const restored = new Uint8Array([0, 97, 115, 109]); + const stale = new Uint8Array([1, 2, 3]); + const selected = selectRunBinaryBytes( + { artifactPath: 'build/app' }, + [{ path: 'build/app', bytes: restored }], + stale + ); + + assert.deepEqual(selected, restored); + assert.equal( + selectRunBinaryBytes({ artifactPath: 'missing' }, [{ path: 'build/app', bytes: restored }], stale), + null + ); +}); + // ── Compile & Run uses the worker-reported output path ──────────────────────── test('e2e: Compile & Run runs the worker output path rather than assuming a.out', () => { diff --git a/scripts/e2e-terminal-git-removal.test.mjs b/scripts/e2e-terminal-git-removal.test.mjs index 032384a..c8ffe44 100644 --- a/scripts/e2e-terminal-git-removal.test.mjs +++ b/scripts/e2e-terminal-git-removal.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + __executeTerminalCommandForTesting, __handleTerminalKeyForTesting, __setTerminalTestHarness, setWorkspace, @@ -99,3 +100,20 @@ test('e2e: folders containing .git remain ordinary workspace content', () => { assert.ok(output.includes('.git/')); assert.ok(output.includes('main.cpp')); }); + +test('e2e: terminal runs a binary restored from the workspace', async () => { + const writes = []; + const runCalls = []; + __setTerminalTestHarness({ + term: { clear() {}, write(text) { writes.push(text); } }, + onRun: (request) => runCalls.push(request), + }); + setWorkspace({ name: 'project', entries: [{ path: 'build/app', kind: 'file' }] }); + + await __executeTerminalCommandForTesting('cd build'); + await __executeTerminalCommandForTesting('./app'); + + assert.equal(runCalls.length, 1); + assert.equal(runCalls[0].artifactPath, 'build/app'); + assert.ok(!writes.join('').includes('No binary found')); +}); diff --git a/src/ui/app.js b/src/ui/app.js index 1f34b79..9421b20 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -33,6 +33,7 @@ import { import { createSessionPersistence, createPersistenceGate } from './session-persistence.mjs'; import { registerPageUnload } from './page-lifecycle.mjs'; import { getExtensionVersionLabel } from '../extension-api.mjs'; +import { selectRunBinaryBytes } from './build-request.mjs'; // ── Boot ────────────────────────────────────────────────────────────────────── @@ -67,7 +68,13 @@ window.addEventListener('DOMContentLoaded', async () => { }, onRun: async (runRequest) => { const vfsFiles = await fsAPI.readAllWorkspaceFiles(); - const binaryBytes = toolbarController?.getLastRunBinaryBytes?.() || null; + const cachedBinaryBytes = toolbarController?.getLastRunBinaryBytes?.() || null; + const binaryBytes = selectRunBinaryBytes(runRequest, vfsFiles, cachedBinaryBytes); + if (runRequest.artifactPath) { + if (!binaryBytes) { + throw new Error(`Could not read binary: ${runRequest.artifactPath}`); + } + } worker.postMessage({ type: 'run', ...runRequest, vfsFiles, binaryBytes }); }, onStdinData: (message) => worker.postMessage(message), diff --git a/src/ui/build-request.mjs b/src/ui/build-request.mjs index 3e8c3c2..1e31cbd 100644 --- a/src/ui/build-request.mjs +++ b/src/ui/build-request.mjs @@ -139,17 +139,45 @@ export function parseGxxArgs(args = []) { * * @param {string} command – e.g. './a.out' or './custom-name' * @param {string|null} lastBuiltArtifactPath - * @returns {{ ok:boolean, error?:string }} + * @param {string[]} workspaceFilePaths + * @returns {{ ok:boolean, path?:string, source?:string, error?:string }} */ -export function resolveRunTarget(command, lastBuiltArtifactPath) { +export function resolveRunTarget(command, lastBuiltArtifactPath, workspaceFilePaths = []) { + const requested = normalizeOverlayPath(String(command || '').replace(/^\.\//, '')); + const workspaceFiles = new Set( + workspaceFilePaths.map((path) => normalizeOverlayPath(path)).filter(Boolean) + ); + + if (workspaceFiles.has(requested)) { + return { ok: true, path: requested, source: 'workspace' }; + } + if (!lastBuiltArtifactPath) { - return { ok: false, error: 'no-binary' }; + return workspaceFiles.size > 0 + ? { ok: false, error: 'not-found' } + : { ok: false, error: 'no-binary' }; } - const requested = normalizeOverlayPath(String(command || '').replace(/^\.\//, '')); + const artifact = normalizeOverlayPath(lastBuiltArtifactPath); const artifactBase = artifact.split('/').pop(); if (requested === artifact || requested === artifactBase) { - return { ok: true }; + return { ok: true, path: artifact, source: 'last-built' }; } return { ok: false, error: 'not-found' }; } + +/** + * Select bytes for a worker run request. Explicit terminal targets always use + * their matching workspace file so an earlier in-memory compile cannot run by + * mistake. Toolbar runs have no target and retain the cached artifact path. + * + * @param {{artifactPath?:string}} request + * @param {Array<{path:string, bytes:Uint8Array}>} workspaceFiles + * @param {Uint8Array|null} cachedBinaryBytes + * @returns {Uint8Array|null} + */ +export function selectRunBinaryBytes(request, workspaceFiles = [], cachedBinaryBytes = null) { + if (!request?.artifactPath) return cachedBinaryBytes; + const artifact = workspaceFiles.find((file) => file.path === request.artifactPath); + return artifact ? new Uint8Array(artifact.bytes) : null; +} diff --git a/src/ui/terminal.js b/src/ui/terminal.js index 2034609..3a4c15c 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -404,10 +404,10 @@ export function setWorkerCapabilities(capabilities = {}) { * * @returns {Promise} whether a valid worker run request was posted */ -export async function startRun() { +export async function startRun({ artifactPath = null } = {}) { if (!term || running || preparingRun) return false; - if (!lastBuiltArtifactPath) { + if (!artifactPath && !lastBuiltArtifactPath) { term.write(`${C.red}No binary found. Compile first with: g++ main.cpp${C.reset}${CRLF}`); writePrompt(); return false; @@ -438,7 +438,7 @@ export async function startRun() { if (!_onRun) throw new Error('Run callback is unavailable.'); term.write(CRLF); - await _onRun(request); + await _onRun(artifactPath ? { ...request, artifactPath } : request); return true; } catch (error) { setRunPreparationState(false); @@ -852,7 +852,12 @@ function cmdGxx(args) { } async function cmdRun(cmd) { - const { ok, error } = resolveRunTarget(cmd, lastBuiltArtifactPath); + const requestedPath = resolveWorkspacePath(workspaceCwd, cmd); + const { ok, path: artifactPath, source, error } = resolveRunTarget( + requestedPath, + lastBuiltArtifactPath, + [...workspaceFiles].map(normalizePath) + ); if (!ok) { if (error === 'no-binary') { term.write(`${C.red}No binary found. Compile first with: g++ main.cpp${C.reset}${CRLF}`); @@ -862,7 +867,7 @@ async function cmdRun(cmd) { writePrompt(); return; } - await startRun(); + await startRun({ artifactPath: source === 'workspace' ? artifactPath : null }); } function cmdLs(args = []) {