diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6738da3..faf510f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,14 +55,18 @@ jobs: run: ./scripts/build - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/steel-node' + if: |- + github.repository == 'stainless-sdks/steel-node' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/steel-node' + if: |- + github.repository == 'stainless-sdks/steel-node' && + !startsWith(github.ref, 'refs/heads/stl/') env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7b51ca0..5e39b94 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.17.0" + ".": "0.18.0" } diff --git a/.stats.yml b/.stats.yml index 416f1d7..35acfcc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 39 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/nen-labs%2Fsteel-45efcdf3e5ccffb6e94a86be505b24b7b4ff05d8f1a2978c2a281729af68cb82.yml -openapi_spec_hash: 9a7724672b05d44888d67b6ed0ffc7ca +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/nen-labs%2Fsteel-f51ce05f6128dcdd6b500492869910d3a43737c66d0bc13c3f2e67f42362605c.yml +openapi_spec_hash: c573fb6f26d5fb14eaf92e0a107323ec config_hash: dce4dea59023b0a00890fa654fbfffb4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f19957..00fd031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.18.0 (2026-03-16) + +Full Changelog: [v0.17.0...v0.18.0](https://github.com/steel-dev/steel-node/compare/v0.17.0...v0.18.0) + +### Features + +* **api:** api update ([84d694c](https://github.com/steel-dev/steel-node/commit/84d694cc49d7d3827e4695f64104eb0bdcefaa2a)) +* **api:** api update ([34de369](https://github.com/steel-dev/steel-node/commit/34de3694682b68fe48f799cc116caa9de8df215c)) +* **api:** api update ([95c42cd](https://github.com/steel-dev/steel-node/commit/95c42cd171f43b25f2b527608210f4c6a0ebdba8)) + + +### Bug Fixes + +* **client:** preserve URL params already embedded in path ([8d952ca](https://github.com/steel-dev/steel-node/commit/8d952ca2518c74a8844e48b3a390e56067f531db)) +* **docs/contributing:** correct pnpm link command ([e3dddca](https://github.com/steel-dev/steel-node/commit/e3dddca3f68711c46b554a84a37c5d9161c26394)) +* **internal:** skip tests that depend on mock server ([d73bebe](https://github.com/steel-dev/steel-node/commit/d73bebeca00b88e47357778c9365b332c8cbd5c2)) +* publish via npm registry in release script ([74f0eae](https://github.com/steel-dev/steel-node/commit/74f0eae3e6987b92dada862eb4ab2a2ba6a22465)) +* publish via npm registry in release script ([700090e](https://github.com/steel-dev/steel-node/commit/700090e76c67b8f6ee44efc41da77997f1b4edce)) + + +### Chores + +* **ci:** skip uploading artifacts on stainless-internal branches ([b5503bb](https://github.com/steel-dev/steel-node/commit/b5503bb501f549a5cc1b61c6e3795af9f733fe9e)) +* **internal:** codegen related update ([4e66ed3](https://github.com/steel-dev/steel-node/commit/4e66ed36fec23cc144fc4a04c2ab564c96df5afb)) +* **internal:** move stringifyQuery implementation to internal function ([ff8f1d9](https://github.com/steel-dev/steel-node/commit/ff8f1d9e4b65ab114f171c93ddba59aeded03192)) +* **test:** do not count install time for mock server timeout ([e42f0e2](https://github.com/steel-dev/steel-node/commit/e42f0e28af237b8c0fc3bb7d45334f6f4f5b5cb2)) +* update mock server docs ([782421b](https://github.com/steel-dev/steel-node/commit/782421bbe1366c326ec5b0e61b6538701d15118f)) +* update placeholder string ([c0b12a4](https://github.com/steel-dev/steel-node/commit/c0b12a4d9f1d946d3c27b819c0bbac3af17fe172)) + ## 0.17.0 (2026-02-06) Full Changelog: [v0.16.0...v0.17.0](https://github.com/steel-dev/steel-node/compare/v0.16.0...v0.17.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcf63ff..c2a9271 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ $ yarn link steel-sdk # With pnpm $ pnpm link --global $ cd ../my-package -$ pnpm link -—global steel-sdk +$ pnpm link --global steel-sdk ``` ## Running tests @@ -68,7 +68,7 @@ $ pnpm link -—global steel-sdk Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. ```sh -$ npx prism mock path/to/your/openapi.yml +$ ./scripts/mock ``` ```sh diff --git a/api.md b/api.md index 4e365d1..7060742 100644 --- a/api.md +++ b/api.md @@ -62,7 +62,7 @@ Methods: - client.sessions.list({ ...params }) -> SessionslistSessionsSessionsCursor - client.sessions.computer(sessionId, { ...params }) -> SessionComputerResponse - client.sessions.context(id) -> SessionContext -- client.sessions.events(id) -> SessionEventsResponse +- client.sessions.events(id, { ...params }) -> SessionEventsResponse - client.sessions.liveDetails(id) -> SessionLiveDetailsResponse - client.sessions.release(id) -> SessionReleaseResponse - client.sessions.releaseAll() -> SessionReleaseAllResponse diff --git a/package.json b/package.json index 1487d20..8d1b622 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "steel-sdk", - "version": "0.17.0", + "version": "0.18.0", "description": "The official TypeScript library for the Steel API", "author": "Steel ", "types": "dist/index.d.ts", diff --git a/scripts/mock b/scripts/mock index 0b28f6e..bcf3b39 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done diff --git a/src/core.ts b/src/core.ts index c55a95a..db70727 100644 --- a/src/core.ts +++ b/src/core.ts @@ -6,6 +6,7 @@ import { APIConnectionTimeoutError, APIUserAbortError, } from './error'; +import { stringifyQuery } from './internal/utils/query'; import { kind as shimsKind, type Readable, @@ -523,32 +524,20 @@ export abstract class APIClient { : new URL(baseURL + (baseURL.endsWith('/') && path.startsWith('/') ? path.slice(1) : path)); const defaultQuery = this.defaultQuery(); - if (!isEmptyObj(defaultQuery)) { - query = { ...defaultQuery, ...query } as Req; + const pathQuery = Object.fromEntries(url.searchParams); + if (!isEmptyObj(defaultQuery) || !isEmptyObj(pathQuery)) { + query = { ...pathQuery, ...defaultQuery, ...query } as Req; } if (typeof query === 'object' && query && !Array.isArray(query)) { - url.search = this.stringifyQuery(query as Record); + url.search = this.stringifyQuery(query); } return url.toString(); } - protected stringifyQuery(query: Record): string { - return Object.entries(query) - .filter(([_, value]) => typeof value !== 'undefined') - .map(([key, value]) => { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - } - if (value === null) { - return `${encodeURIComponent(key)}=`; - } - throw new SteelError( - `Cannot stringify type ${typeof value}; Expected string, number, boolean, or null. If you need to pass nested query parameters, you can manually encode them, e.g. { query: { 'foo[key1]': value1, 'foo[key2]': value2 } }, and please open a GitHub issue requesting better support for your use case.`, - ); - }) - .join('&'); + protected stringifyQuery(query: object | Record): string { + return stringifyQuery(query); } async fetchWithTimeout( @@ -634,9 +623,9 @@ export abstract class APIClient { } } - // If the API asks us to wait a certain amount of time (and it's a reasonable amount), - // just do what it says, but otherwise calculate a default - if (!(timeoutMillis && 0 <= timeoutMillis && timeoutMillis < 60 * 1000)) { + // If the API asks us to wait a certain amount of time, do what it says. + // Otherwise calculate a default. + if (timeoutMillis === undefined) { const maxRetries = options.maxRetries ?? this.maxRetries; timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); } diff --git a/src/index.ts b/src/index.ts index cbcabdc..5695168 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ import { SessionComputerResponse, SessionContext, SessionCreateParams, + SessionEventsParams, SessionEventsResponse, SessionListParams, SessionLiveDetailsResponse, @@ -313,6 +314,7 @@ export declare namespace Steel { type SessionCreateParams as SessionCreateParams, type SessionListParams as SessionListParams, type SessionComputerParams as SessionComputerParams, + type SessionEventsParams as SessionEventsParams, type SessionReleaseParams as SessionReleaseParams, type SessionReleaseAllParams as SessionReleaseAllParams, }; diff --git a/src/internal/utils/query.ts b/src/internal/utils/query.ts new file mode 100644 index 0000000..b501944 --- /dev/null +++ b/src/internal/utils/query.ts @@ -0,0 +1,23 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { SteelError } from '../../error'; + +/** + * Basic re-implementation of `qs.stringify` for primitive types. + */ +export function stringifyQuery(query: object | Record) { + return Object.entries(query) + .filter(([_, value]) => typeof value !== 'undefined') + .map(([key, value]) => { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } + if (value === null) { + return `${encodeURIComponent(key)}=`; + } + throw new SteelError( + `Cannot stringify type ${typeof value}; Expected string, number, boolean, or null. If you need to pass nested query parameters, you can manually encode them, e.g. { query: { 'foo[key1]': value1, 'foo[key2]': value2 } }, and please open a GitHub issue requesting better support for your use case.`, + ); + }) + .join('&'); +} diff --git a/src/resources/index.ts b/src/resources/index.ts index 62e9810..0b2fa24 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -46,6 +46,7 @@ export { type SessionCreateParams, type SessionListParams, type SessionComputerParams, + type SessionEventsParams, type SessionReleaseParams, type SessionReleaseAllParams, } from './sessions/sessions'; diff --git a/src/resources/sessions/index.ts b/src/resources/sessions/index.ts index e41c1e2..961a51a 100644 --- a/src/resources/sessions/index.ts +++ b/src/resources/sessions/index.ts @@ -23,6 +23,7 @@ export { type SessionCreateParams, type SessionListParams, type SessionComputerParams, + type SessionEventsParams, type SessionReleaseParams, type SessionReleaseAllParams, } from './sessions'; diff --git a/src/resources/sessions/sessions.ts b/src/resources/sessions/sessions.ts index 7bdf043..c214b2f 100644 --- a/src/resources/sessions/sessions.ts +++ b/src/resources/sessions/sessions.ts @@ -80,8 +80,21 @@ export class Sessions extends APIResource { /** * This endpoint allows you to get the recorded session events in the RRWeb format */ - events(id: string, options?: Core.RequestOptions): Core.APIPromise { - return this._client.get(`/v1/sessions/${id}/events`, options); + events( + id: string, + query?: SessionEventsParams, + options?: Core.RequestOptions, + ): Core.APIPromise; + events(id: string, options?: Core.RequestOptions): Core.APIPromise; + events( + id: string, + query: SessionEventsParams | Core.RequestOptions = {}, + options?: Core.RequestOptions, + ): Core.APIPromise { + if (isRequestOptions(query)) { + return this.events(id, {}, query); + } + return this._client.get(`/v1/sessions/${id}/events`, { query, ...options }); } /** @@ -508,9 +521,10 @@ export interface Sessionslist { sessions: Array; /** - * Total number of sessions matching the query + * Total number of sessions matching the query. Only included for filtered queries + * (e.g. status=live). */ - totalCount: number; + totalCount?: number; } export namespace Sessionslist { @@ -837,6 +851,11 @@ export interface SessionCreateParams { */ dimensions?: SessionCreateParams.Dimensions; + /** + * Enable experimental features for the session. + */ + experimentalFeatures?: Array; + /** * Array of extension IDs to install in the session. Use ['all_ext'] to install all * uploaded extensions. @@ -2727,6 +2746,23 @@ export declare namespace SessionComputerParams { } } +export interface SessionEventsParams { + /** + * Compress the events + */ + compressed?: boolean; + + /** + * Optional pagination limit + */ + limit?: number; + + /** + * Opaque pagination token. Pass the Next-Cursor header value to get the next page. + */ + pointer?: string; +} + export interface SessionReleaseParams {} export interface SessionReleaseAllParams {} @@ -2749,6 +2785,7 @@ export declare namespace Sessions { type SessionCreateParams as SessionCreateParams, type SessionListParams as SessionListParams, type SessionComputerParams as SessionComputerParams, + type SessionEventsParams as SessionEventsParams, type SessionReleaseParams as SessionReleaseParams, type SessionReleaseAllParams as SessionReleaseAllParams, }; diff --git a/src/version.ts b/src/version.ts index 0251da7..74131f9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.17.0'; // x-release-please-version +export const VERSION = '0.18.0'; // x-release-please-version diff --git a/tests/api-resources/extensions.test.ts b/tests/api-resources/extensions.test.ts index b2867ca..494434e 100644 --- a/tests/api-resources/extensions.test.ts +++ b/tests/api-resources/extensions.test.ts @@ -29,7 +29,7 @@ describe('resource extensions', () => { await expect( client.extensions.update( 'extensionId', - { file: await toFile(Buffer.from('# my file contents'), 'README.md'), url: 'https://example.com' }, + { file: await toFile(Buffer.from('Example data'), 'README.md'), url: 'https://example.com' }, { path: '/_stainless_unknown_path' }, ), ).rejects.toThrow(Steel.NotFoundError); @@ -129,7 +129,7 @@ describe('resource extensions', () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( client.extensions.upload( - { file: await toFile(Buffer.from('# my file contents'), 'README.md'), url: 'https://example.com' }, + { file: await toFile(Buffer.from('Example data'), 'README.md'), url: 'https://example.com' }, { path: '/_stainless_unknown_path' }, ), ).rejects.toThrow(Steel.NotFoundError); diff --git a/tests/api-resources/files.test.ts b/tests/api-resources/files.test.ts index de7a347..ab17d35 100644 --- a/tests/api-resources/files.test.ts +++ b/tests/api-resources/files.test.ts @@ -42,7 +42,8 @@ describe('resource files', () => { ); }); - test('download: request options instead of params are passed correctly', async () => { + // Mock server doesn't support application/octet-stream responses + test.skip('download: request options instead of params are passed correctly', async () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect(client.files.download('path', { path: '/_stainless_unknown_path' })).rejects.toThrow( Steel.NotFoundError, @@ -51,7 +52,7 @@ describe('resource files', () => { test('upload: only required params', async () => { const responsePromise = client.files.upload({ - file: await toFile(Buffer.from('# my file contents'), 'README.md'), + file: await toFile(Buffer.from('Example data'), 'README.md'), }); const rawResponse = await responsePromise.asResponse(); expect(rawResponse).toBeInstanceOf(Response); @@ -64,7 +65,7 @@ describe('resource files', () => { test('upload: required and optional params', async () => { const response = await client.files.upload({ - file: await toFile(Buffer.from('# my file contents'), 'README.md'), + file: await toFile(Buffer.from('Example data'), 'README.md'), path: 'path', }); }); diff --git a/tests/api-resources/profiles.test.ts b/tests/api-resources/profiles.test.ts index 799be0a..e0926d1 100644 --- a/tests/api-resources/profiles.test.ts +++ b/tests/api-resources/profiles.test.ts @@ -8,7 +8,7 @@ const client = new Steel({ baseURL: process.env['TEST_API_BASE_URL'] ?? 'http:// describe('resource profiles', () => { test('create: only required params', async () => { const responsePromise = client.profiles.create({ - userDataDir: await toFile(Buffer.from('# my file contents'), 'README.md'), + userDataDir: await toFile(Buffer.from('Example data'), 'README.md'), }); const rawResponse = await responsePromise.asResponse(); expect(rawResponse).toBeInstanceOf(Response); @@ -21,7 +21,7 @@ describe('resource profiles', () => { test('create: required and optional params', async () => { const response = await client.profiles.create({ - userDataDir: await toFile(Buffer.from('# my file contents'), 'README.md'), + userDataDir: await toFile(Buffer.from('Example data'), 'README.md'), dimensions: { height: 0, width: 0 }, proxyUrl: 'https://example.com', userAgent: 'userAgent', @@ -30,7 +30,7 @@ describe('resource profiles', () => { test('update: only required params', async () => { const responsePromise = client.profiles.update('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { - userDataDir: await toFile(Buffer.from('# my file contents'), 'README.md'), + userDataDir: await toFile(Buffer.from('Example data'), 'README.md'), }); const rawResponse = await responsePromise.asResponse(); expect(rawResponse).toBeInstanceOf(Response); @@ -43,7 +43,7 @@ describe('resource profiles', () => { test('update: required and optional params', async () => { const response = await client.profiles.update('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { - userDataDir: await toFile(Buffer.from('# my file contents'), 'README.md'), + userDataDir: await toFile(Buffer.from('Example data'), 'README.md'), dimensions: { height: 0, width: 0 }, proxyUrl: 'https://example.com', userAgent: 'userAgent', diff --git a/tests/api-resources/sessions/files.test.ts b/tests/api-resources/sessions/files.test.ts index 587d41b..cc343b0 100644 --- a/tests/api-resources/sessions/files.test.ts +++ b/tests/api-resources/sessions/files.test.ts @@ -60,14 +60,16 @@ describe('resource files', () => { ).rejects.toThrow(Steel.NotFoundError); }); - test('download: request options instead of params are passed correctly', async () => { + // Mock server doesn't support application/octet-stream responses + test.skip('download: request options instead of params are passed correctly', async () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( client.sessions.files.download('sessionId', 'path', { path: '/_stainless_unknown_path' }), ).rejects.toThrow(Steel.NotFoundError); }); - test('downloadArchive: request options instead of params are passed correctly', async () => { + // Mock server doesn't support application/zip responses + test.skip('downloadArchive: request options instead of params are passed correctly', async () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( client.sessions.files.downloadArchive('sessionId', { path: '/_stainless_unknown_path' }), @@ -76,7 +78,7 @@ describe('resource files', () => { test('upload: only required params', async () => { const responsePromise = client.sessions.files.upload('sessionId', { - file: await toFile(Buffer.from('# my file contents'), 'README.md'), + file: await toFile(Buffer.from('Example data'), 'README.md'), }); const rawResponse = await responsePromise.asResponse(); expect(rawResponse).toBeInstanceOf(Response); @@ -89,7 +91,7 @@ describe('resource files', () => { test('upload: required and optional params', async () => { const response = await client.sessions.files.upload('sessionId', { - file: await toFile(Buffer.from('# my file contents'), 'README.md'), + file: await toFile(Buffer.from('Example data'), 'README.md'), path: 'path', }); }); diff --git a/tests/api-resources/sessions/sessions.test.ts b/tests/api-resources/sessions/sessions.test.ts index ee590b0..244db8d 100644 --- a/tests/api-resources/sessions/sessions.test.ts +++ b/tests/api-resources/sessions/sessions.test.ts @@ -32,6 +32,7 @@ describe('resource sessions', () => { debugConfig: { interactive: true, systemCursor: true }, deviceConfig: { device: 'desktop' }, dimensions: { height: -9007199254740991, width: -9007199254740991 }, + experimentalFeatures: ['string'], extensionIds: ['string'], headless: true, isSelenium: true, @@ -220,6 +221,21 @@ describe('resource sessions', () => { ).rejects.toThrow(Steel.NotFoundError); }); + test('events: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.sessions.events( + '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + { + compressed: true, + limit: 1, + pointer: 'pointer', + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Steel.NotFoundError); + }); + test('liveDetails', async () => { const responsePromise = client.sessions.liveDetails('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); const rawResponse = await responsePromise.asResponse(); diff --git a/tests/stringifyQuery.test.ts b/tests/stringifyQuery.test.ts index 0ae97de..5622892 100644 --- a/tests/stringifyQuery.test.ts +++ b/tests/stringifyQuery.test.ts @@ -1,8 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { Steel } from 'steel-sdk'; - -const { stringifyQuery } = Steel.prototype as any; +import { stringifyQuery } from 'steel-sdk/internal/utils/query'; describe(stringifyQuery, () => { for (const [input, expected] of [ @@ -15,7 +13,7 @@ describe(stringifyQuery, () => { 'e=f', )}=${encodeURIComponent('g&h')}`, ], - ]) { + ] as const) { it(`${JSON.stringify(input)} -> ${expected}`, () => { expect(stringifyQuery(input)).toEqual(expected); });