Skip to content
Open
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 docs/gotchas/tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ When a section grows to 10+ items, graduate it to its own doc.

- **The watch service and the lock file do not share a version vocabulary.** A watch's `baselineVersion`, and the `currentVersion` it reports back, are the *asset-delivery content hash* for the place — that's what the service's Roblox driver reads, and it compares them by string equality. `deploy.nevermore.lock.json` holds the *Open Cloud place version* (an integer). They are never equal, so handing the lock's number over as a baseline reads as drift on the very first poll and dispatches a rebuild of the build that just shipped. The CLI therefore sends no baseline at all — the service's first poll adopts what it sees, which is what a baseline was for — and treats every version the service reports as an opaque "something moved" token, asking Open Cloud what the place is actually at before deciding to rebuild. Anything comparing a service version against a lock version is wrong even when the types line up.

- **Open Cloud truncates long engine logs, so anything that must be read back exactly belongs in the script's return value.** A script printing 20,001 lines came back with 7,627 of them — the last contiguous block, with the head dropped — and the size of the window varies run to run (6,715 and 3,236 lines on other runs), so there is no line count to stay under. A Luau execution task's return value is not subject to this: it arrives on the task as `output.results`, a flat array of the returned values (`return t, "s", 42` → three entries), and Roblox serializes them itself, so a returned Lua table is real nested JSON. Do **not** `HttpService:JSONEncode` the result — that lands as a double-encoded string.

- **An oversize return value annihilates the task rather than truncating the value.** A ~2.1MB return value arrives complete and intact; a ~4.2MB one fails the whole task — state `FAILED`, no `output`, and no error message saying why. So code reading a return value has to treat "the task reported no output" as *unknown* rather than empty, and fall back to the logs; `getTaskReturnValues` in `open-cloud-client.ts` draws exactly that line (`undefined` = nothing came back, `[]` = the script returned nothing).

- **`--script-text` loses everything after the first line when invoked through `npx` on Windows**: the `npx.cmd` shim truncates a multi-line argument, so `nevermore test --cloud --script-text '<line 1>\n<line 2>'` silently runs only line 1 (and prints `(no output)` when line 1 produced none). Either write the script as a single line with `;` separators, or bypass the shim: `node tools/nevermore-cli/dist/nevermore.js test --cloud --script-text '...'`, which passes newlines through intact.

## Claude Code hooks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export class BatchScriptJobContext implements JobContext {
success: result.success,
durationMs: result.durationMs,
errorMessage: result.error,
// returnValues stays absent: one execution covers every package, so the
// single return value it produces has to be split per package before any
// of it can surface here.
};
}

Expand Down
112 changes: 112 additions & 0 deletions tools/nevermore-cli/src/utils/job-context/cloud-job-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Unit tests for CloudJobContext.runScriptAsync — validates what a finished
* Open Cloud task reports back as a ScriptRunResult, in particular that a task
* which produced no output stays distinguishable from one that returned nothing.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { type Reporter } from '@quenty/cli-output-helpers/reporting';
import { CloudJobContext } from './cloud-job-context.js';
import {
type LuauTask,
type OpenCloudClient,
} from '../open-cloud/open-cloud-client.js';
import { type Deployment } from './job-context.js';

function createReporter(): Reporter {
return {
onPackagePhaseChange: vi.fn(),
onPackageProgressUpdate: vi.fn(),
onPackageStart: vi.fn(),
onPackageResult: vi.fn(),
} as unknown as Reporter;
}

/**
* A client whose task completes as `completedTask`. The real deployment handle
* is private to the context, so the test passes the three fields runScriptAsync
* reads off it.
*/
function createContext(completedTask: Partial<LuauTask>) {
const task = {
path: 'universes/1/places/2/versions/3/luau-execution-session-tasks/4',
createTime: '2026-01-01T00:00:00Z',
updateTime: '2026-01-01T00:01:00Z',
user: 'users/1',
state: 'COMPLETE',
script: 'return 1',
...completedTask,
} as LuauTask;

const client = {
createExecutionTaskAsync: vi.fn(async () => task),
pollTaskCompletionAsync: vi.fn(async () => task),
} as unknown as OpenCloudClient;

const context = new CloudJobContext(createReporter(), client);
const deployment = {
universeId: 1,
placeId: 2,
version: 3,
} as unknown as Deployment;

return { context, deployment };
}

describe('CloudJobContext.runScriptAsync', () => {
afterEach(() => {
vi.useRealTimers();
});

it('reports the values the script returned', async () => {
// Fake timers keep the client-side timeout race from leaving a live timer
// behind; nothing in the run itself waits on one.
vi.useFakeTimers();
const { context, deployment } = createContext({
state: 'COMPLETE',
output: { results: [{ slug: 'maid', counts: { passed: 1014 } }] },
});

const result = await context.runScriptAsync(deployment, {
scriptContent: 'return results',
packageName: 'maid',
});

expect(result.success).toBe(true);
expect(result.returnValues).toEqual([
{ slug: 'maid', counts: { passed: 1014 } },
]);
});

it('reports an empty result when the task returned nothing', async () => {
vi.useFakeTimers();
const { context, deployment } = createContext({
state: 'COMPLETE',
output: {},
});

const result = await context.runScriptAsync(deployment, {
scriptContent: 'print("hi")',
packageName: 'maid',
});

expect(result.returnValues).toEqual([]);
});

it('leaves returnValues absent when a failed task carried no output', async () => {
vi.useFakeTimers();
const { context, deployment } = createContext({
state: 'FAILED',
output: undefined,
});

const result = await context.runScriptAsync(deployment, {
scriptContent: 'return huge',
packageName: 'maid',
});

expect(result.success).toBe(false);
expect(result.taskState).toBe('FAILED');
expect(result.returnValues).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Reporter } from '@quenty/cli-output-helpers/reporting';
import {
getTaskReturnValues,
type LuauTask,
type OpenCloudClient,
} from '../open-cloud/open-cloud-client.js';
Expand Down Expand Up @@ -134,6 +135,7 @@ export class CloudJobContext extends BaseJobContext {
success: completedTask.state === 'COMPLETE',
taskState: completedTask.state,
errorMessage,
returnValues: getTaskReturnValues(completedTask),
};
}

Expand Down
21 changes: 21 additions & 0 deletions tools/nevermore-cli/src/utils/job-context/job-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ export interface ScriptRunResult {
taskState?: string;
/** Error message from the execution backend, if any. */
errorMessage?: string;
/**
* Everything the executed script returned, in order — the structured channel
* out of a run, as opposed to its printed output. Engine logs are truncated
* by Open Cloud on long runs, so anything a caller must read back exactly
* belongs here rather than in the log text.
*
* `undefined` means the transport never delivered a return channel: a cloud
* task that ended without an `output` (a FAILED task carries none, and an
* oversize return value fails the task rather than truncating the value), a
* bridge run that timed out or disconnected, or a context that does not
* carry return values at all. That is deliberately distinct from `[]`, which
* means the script ran and returned nothing — a caller that needs the value
* can fall back to parsing logs in the first case but not the second.
*
* Values are JSON-shaped, but the two transports spell exotic Luau types
* differently: Open Cloud auto-serializes them, while the Studio bridge
* marshals them into `{ type, value }` wrappers (`SerializedReturnValue`).
* Plain tables of strings, numbers and booleans come back identically on
* both, so structured results should stay inside that subset.
*/
returnValues?: unknown[];
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Unit tests for LocalJobContext.runScriptAsync — validates that a bridge run
* hands its script return values on as a ScriptRunResult, and that a run which
* never completed reports none rather than an empty result.
*/

import { describe, it, expect, vi } from 'vitest';
import { type Reporter } from '@quenty/cli-output-helpers/reporting';
import { LocalJobContext } from './local-job-context.js';
import { type Deployment } from './job-context.js';

function createReporter(): Reporter {
return {
onPackagePhaseChange: vi.fn(),
onPackageProgressUpdate: vi.fn(),
onPackageStart: vi.fn(),
onPackageResult: vi.fn(),
} as unknown as Reporter;
}

/**
* The deployment handle is private to the context, so the test stands in a
* bridge with the one method runScriptAsync calls on it.
*/
function createDeployment(
executeAsync: () => Promise<{
success: boolean;
logs: string;
returnValues?: unknown[];
}>
): Deployment {
return {
bridge: { executeAsync },
cachedLogs: '',
} as unknown as Deployment;
}

describe('LocalJobContext.runScriptAsync', () => {
it('reports the values the script returned', async () => {
const context = new LocalJobContext(createReporter());
const deployment = createDeployment(async () => ({
success: true,
logs: 'ran',
returnValues: [{ counts: { passed: 7 } }],
}));

const result = await context.runScriptAsync(deployment, {
scriptContent: 'return results',
packageName: 'maid',
});

expect(result.success).toBe(true);
expect(result.returnValues).toEqual([{ counts: { passed: 7 } }]);
expect(await context.getLogsAsync(deployment)).toBe('ran');
});

it('leaves returnValues absent when the bridge reported none', async () => {
const context = new LocalJobContext(createReporter());
const deployment = createDeployment(async () => ({
success: false,
logs: '[StudioBridge] Timed out after 200ms',
}));

const result = await context.runScriptAsync(deployment, {
scriptContent: 'while true do end',
packageName: 'maid',
});

expect(result.returnValues).toBeUndefined();
});

it('leaves returnValues absent when the bridge throws', async () => {
const context = new LocalJobContext(createReporter());
const deployment = createDeployment(async () => {
throw new Error('no connected client');
});

const result = await context.runScriptAsync(deployment, {
scriptContent: 'return results',
packageName: 'maid',
});

expect(result.success).toBe(false);
expect(result.returnValues).toBeUndefined();
expect(await context.getLogsAsync(deployment)).toContain(
'no connected client'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class LocalJobContext extends BaseJobContext {
timeoutMs,
});
localDeployment.cachedLogs = result.logs;
return { success: result.success };
return { success: result.success, returnValues: result.returnValues };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import { OpenCloudClient } from './open-cloud-client.js';
import {
OpenCloudClient,
getTaskReturnValues,
type LuauTask,
} from './open-cloud-client.js';
import type { RateLimiter } from './rate-limiter.js';

vi.mock('@quenty/cli-output-helpers', () => ({
Expand Down Expand Up @@ -367,3 +371,47 @@ describe('OpenCloudClient.resolveLatestPlaceVersionAsync', () => {
).rejects.toThrowError(/unparseable version path/);
});
});

describe('getTaskReturnValues', () => {
function makeTask(overrides: Partial<LuauTask>): LuauTask {
return {
path: 'universes/1/places/2/versions/3/luau-execution-session-tasks/4',
createTime: '2026-01-01T00:00:00Z',
updateTime: '2026-01-01T00:01:00Z',
user: 'users/1',
state: 'COMPLETE',
script: 'return 1',
...overrides,
};
}

it('returns the values natively typed, one entry per returned value', () => {
// Roblox serializes the return value itself, so a returned table arrives as
// real nested JSON — there is no JSON string to parse a second time.
const task = makeTask({
output: {
results: [{ slug: 'maid', counts: { passed: 1014 } }, 'str', 42, true],
},
});

expect(getTaskReturnValues(task)).toEqual([
{ slug: 'maid', counts: { passed: 1014 } },
'str',
42,
true,
]);
});

it('reports an empty result when the task returned nothing', () => {
expect(getTaskReturnValues(makeTask({ output: {} }))).toEqual([]);
});

it('reports undefined when a failed task carried no output at all', () => {
// An oversize return value fails the task with no output and no error
// message, so "nothing came back to read" has to stay distinguishable from
// "the script returned nothing" — only the former can fall back to logs.
const task = makeTask({ state: 'FAILED', output: undefined });

expect(getTaskReturnValues(task)).toBeUndefined();
});
});
30 changes: 28 additions & 2 deletions tools/nevermore-cli/src/utils/open-cloud/open-cloud-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,38 @@ export interface LuauTask {
| 'FAILED';
script: string;
timeout?: string;
/** Script return value (populated on COMPLETE). */
output?: { results?: Array<{ value?: string }> };
/**
* What the script returned, populated on COMPLETE. `results` is a flat array
* of the returned values — `return t, "s", 42` arrives as three entries —
* natively typed: Roblox serializes them itself, so a returned Lua table is
* real nested JSON here, not a JSON string. (Which is also why a script must
* not JSONEncode its result: that lands as a double-encoded string.)
*
* Absent whenever the task produced no result. A FAILED task carries no
* `output` at all, with no error message explaining why — and an oversize
* return value (~4MB observed; ~2MB still arrives complete) fails the task
* exactly that way rather than truncating the value.
*/
output?: { results?: unknown[] };
/** Error details (populated on FAILED). */
error?: { code?: string; message?: string };
}

/**
* The values a finished task's script returned, or `undefined` when the task
* reported no result at all.
*
* The distinction matters: `undefined` means nothing came back to read, so a
* caller can fall back to the task's logs, while `[]` means the task did report
* a result and the script returned nothing — no fallback will find more.
*/
export function getTaskReturnValues(task: LuauTask): unknown[] | undefined {
if (!task.output) {
return undefined;
}
return task.output.results ?? [];
}

export interface OpenCloudClientOptions {
apiKey: string | (() => Promise<string>);
rateLimiter: RateLimiter;
Expand Down
Loading
Loading