Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/workflows/build-package-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Build Package Test

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

jobs:
build-package-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: runloopai/checkout@main

- name: Setup Node
uses: runloopai/setup-node@main
with:
node-version: '20'
cache: 'yarn'

- name: Install dependencies
run: yarn --frozen-lockfile

- name: Run build package test
run: yarn test:built-package

14 changes: 10 additions & 4 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { JestConfigWithTsJest } from 'ts-jest';

const runSmoketests = process.env['RUN_SMOKETESTS'] === '1';
const runBuiltPackageTest = process.env['RUN_BUILT_PACKAGE_TEST'] === '1';

const config: JestConfigWithTsJest = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
testTimeout: runSmoketests ? 300000 : 120000, // 5 minutes for smoke tests, 2 minutes for regular tests
testTimeout: runSmoketests || runBuiltPackageTest ? 300000 : 120000, // 5 minutes for smoke tests, 2 minutes for regular tests
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest', { sourceMaps: 'inline' }],
},
Expand All @@ -23,12 +24,17 @@ const config: JestConfigWithTsJest = {
testPathIgnorePatterns: [
'scripts',
// When running smoke tests, ignore regular tests; when running regular tests, ignore smoke tests
...(runSmoketests ?
['<rootDir>/tests/(?!smoketests).*'] // Ignore all test files except those in smoketests/
...(runSmoketests && !runBuiltPackageTest ?
[
'<rootDir>/tests/(?!smoketests).*', // Ignore all test files except those in smoketests/
'<rootDir>/tests/smoketests/build-package.test.ts', // Exclude build-package test from regular smoke test runs
]
: runBuiltPackageTest ?
[] // Don't ignore anything when running built-package test
: ['<rootDir>/tests/smoketests/']), // Ignore smoke tests when running regular tests
],
// Add display name for smoke tests to make it clearer in output
...(runSmoketests && { displayName: 'Smoke Tests' }),
...((runSmoketests || runBuiltPackageTest) && { displayName: 'Smoke Tests' }),
};

export default config;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"test:objects": "RUN_SMOKETESTS=1 jest --config jest.config.objects.js --verbose",
"test:objects-coverage": "RUN_SMOKETESTS=1 jest --verbose --config jest.config.objects.js --coverage --coverageReporters=text --coverageReporters=json-summary",
"test:objects-coverage:html": "RUN_SMOKETESTS=1 jest --verbose --config jest.config.objects.js --coverage --coverageReporters=html --coverageReporters=text && open coverage-objects/index.html",
"test:built-package": "yarn build && RUN_BUILT_PACKAGE_TEST=1 jest --verbose tests/smoketests/build-package.test.ts",
"build": "./scripts/build",
"prepublishOnly": "echo 'to publish, run yarn build && (cd dist; yarn publish)' && exit 1",
"format": "prettier --write --cache --cache-strategy metadata . !dist",
Expand Down
6 changes: 4 additions & 2 deletions scripts/utils/fix-index-exports.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ const sdkJs =
: path.resolve(__dirname, '..', '..', 'dist', 'sdk.js');

let before = fs.readFileSync(sdkJs, 'utf8');
// Match exports.default = <anything> and preserve all existing exports
// by ensuring module.exports points to the exports object instead of replacing it
let after = before.replace(
/^\s*exports\.default\s*=\s*(\w+)/m,
'exports = module.exports = $1;\nexports.default = $1',
/^\s*exports\.default\s*=\s*([^;]+);/m,
'module.exports = exports;\nexports.default = $1;',
Comment on lines +13 to +14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The regex ([^;]+) stops at the first semicolon, so if the default export expression contains additional semicolons (e.g. a function body) the replacement truncates the expression and corrupts sdk.js; replace just the exports.default = prefix and insert module.exports = exports before it so the rest of the original expression (and its semicolons) remain untouched. [logic error]

Severity Level: Minor ⚠️

Suggested change
/^\s*exports\.default\s*=\s*([^;]+);/m,
'module.exports = exports;\nexports.default = $1;',
/^(\s*)exports\.default\s*=\s*/m,
'$1module.exports = exports;\n$1exports.default = ',
Why it matters? ⭐

The current regex captures only until the next semicolon, so if exports.default is set to a function or object literal that contains other semicolons (common in the bundled sdk.js), the replacement slices the expression mid-stream and corrupts the file. The suggested change simply prefixes the existing assignment with module.exports = exports; while leaving the rest untouched, which avoids breaking any valid exports.default expression.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** scripts/utils/fix-index-exports.cjs
**Line:** 13:14
**Comment:**
	*Logic Error: The regex `([^;]+)` stops at the first semicolon, so if the default export expression contains additional semicolons (e.g. a function body) the replacement truncates the expression and corrupts `sdk.js`; replace just the `exports.default =` prefix and insert `module.exports = exports` before it so the rest of the original expression (and its semicolons) remain untouched.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

);
fs.writeFileSync(sdkJs, after, 'utf8');
155 changes: 155 additions & 0 deletions tests/smoketests/build-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {
RunloopSDK,
RunloopAPI,
Devbox,
Blueprint,
Snapshot,
StorageObject,
DevboxOps,
BlueprintOps,
SnapshotOps,
StorageObjectOps,
DevboxCmdOps,
DevboxFileOps,
DevboxNetOps,
Execution,
ExecutionResult,
type ExecuteStreamingCallbacks,
type ClientOptions,
} from '../../dist/sdk';

describe('smoketest: built package import', () => {
let sdk: RunloopSDK;

beforeAll(() => {
// Initialize SDK from built package - using dummy token since we're only testing imports/types
sdk = new RunloopSDK({
bearerToken: 'dummy-token-for-import-test',
baseURL: 'https://api.runloop.ai',
timeout: 120_000,
maxRetries: 1,
});
});

describe('RunloopSDK from built package', () => {
test('should create SDK instance from built package', () => {
expect(sdk).toBeDefined();
expect(sdk.devbox).toBeDefined();
expect(sdk.blueprint).toBeDefined();
expect(sdk.snapshot).toBeDefined();
expect(sdk.storageObject).toBeDefined();
expect(sdk.api).toBeDefined();
});

test('should provide access to legacy API', () => {
expect(sdk.api).toBeDefined();
expect(sdk.api.devboxes).toBeDefined();
expect(sdk.api.blueprints).toBeDefined();
expect(sdk.api.objects).toBeDefined();
});

test('should verify RunloopSDK namespace exports are available', () => {
// Test that namespace exports are accessible
// These are exported from the RunloopSDK namespace
expect(DevboxOps).toBeDefined();
expect(BlueprintOps).toBeDefined();
expect(SnapshotOps).toBeDefined();
expect(StorageObjectOps).toBeDefined();
expect(Devbox).toBeDefined();
expect(Blueprint).toBeDefined();
expect(Snapshot).toBeDefined();
expect(StorageObject).toBeDefined();
});

test('should verify additional SDK classes are available', () => {
expect(DevboxCmdOps).toBeDefined();
expect(DevboxFileOps).toBeDefined();
expect(DevboxNetOps).toBeDefined();
expect(Execution).toBeDefined();
expect(ExecutionResult).toBeDefined();
});

test('should verify types are available', () => {
// Type check - if this compiles, the type is available
const callback: ExecuteStreamingCallbacks = {
stdout: () => {},
stderr: () => {},
output: () => {},
};
expect(callback).toBeDefined();
expect(typeof callback.stdout).toBe('function');
expect(typeof callback.stderr).toBe('function');
expect(typeof callback.output).toBe('function');
});

test('should allow wrapping runloop types', () => {
// Test that types can be imported and used for type annotations
const options: ClientOptions = {
bearerToken: 'test-token',
baseURL: 'https://api.runloop.ai',
timeout: 120_000,
maxRetries: 1,
};
expect(options).toBeDefined();
expect(options.bearerToken).toBeDefined();
expect(options.baseURL).toBeDefined();

// Verify type wrapping works by creating a wrapper function
function createSDKWithOptions(opts: ClientOptions): RunloopSDK {
return new RunloopSDK(opts);
}
const wrappedSDK = createSDKWithOptions(options);
expect(wrappedSDK).toBeInstanceOf(RunloopSDK);
});

test('should verify RunloopAPI namespace and nested resources', () => {
expect(RunloopAPI).toBeDefined();
expect(RunloopAPI.Devboxes).toBeDefined();
expect(RunloopAPI.Blueprints).toBeDefined();
expect(RunloopAPI.Objects).toBeDefined();
expect(RunloopAPI.Secrets).toBeDefined();
expect(RunloopAPI.Agents).toBeDefined();
expect(RunloopAPI.Benchmarks).toBeDefined();
expect(RunloopAPI.Scenarios).toBeDefined();
expect(RunloopAPI.Repositories).toBeDefined();
});

test('should allow creating new types based on execution.result() return type', () => {
// Extract the return type from execution.result()
// execution.result() returns Promise<ExecutionResult>
type ExecutionResultType = Awaited<ReturnType<Execution['result']>>;

// Create a new type based on the extracted type
type WrappedExecutionResult = {
result: ExecutionResultType;
timestamp: number;
metadata?: Record<string, unknown>;
};

// Verify the type works by creating an instance
// Note: We can't actually call execution.result() without a real execution,
// but we can verify the type extraction works by using ExecutionResult directly
const mockResult = new ExecutionResult(sdk.api, 'devbox-123', 'execution-456', {
execution_id: 'execution-456',
devbox_id: 'devbox-123',
status: 'completed',
exit_code: 0,
} as any);

const wrapped: WrappedExecutionResult = {
result: mockResult,
timestamp: Date.now(),
metadata: { test: true },
};

expect(wrapped).toBeDefined();
expect(wrapped.result).toBeInstanceOf(ExecutionResult);
expect(wrapped.timestamp).toBeGreaterThan(0);
expect(wrapped.metadata?.['test']).toBe(true);

// Verify the type is correctly inferred
const resultType: ExecutionResultType = mockResult;
expect(resultType).toBeInstanceOf(ExecutionResult);
});
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"include": ["src", "tests", "examples"],
"exclude": ["src/_shims/**/*-deno.ts"],
"exclude": ["src/_shims/**/*-deno.ts", "tests/smoketests/build-package.test.ts"],
"compilerOptions": {
"target": "es2020",
"lib": ["es2020"],
Expand Down