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
194 changes: 194 additions & 0 deletions .claude/skills/docs-sync/SKILL.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
STREAM_API_KEY=<API key>
STREAM_SECRET=<SECRET>
OPENAI_API_KEY=<OpenAI API key>

# Optional, for the docs snippet tests (__tests__/docs-snippets): Firebase service account JSON on one line.
# Push tests are skipped when it is not set.
DOCS_PUSH_FIREBASE_CREDENTIALS=
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ src/
StreamFeedsClient.ts # Feeds-specific client
StreamModerationClient.ts # Moderation-specific client
__tests__/ # Test files
*.test.ts # Test files for each module
integration/*.test.ts # Test files for each module (`yarn test`)
docs-snippets/ # Docs snippet tests (`yarn test:docs`, see its README.md)
```

Use the closest folder's patterns and conventions when editing.
Expand Down Expand Up @@ -132,7 +133,7 @@ Commit / PR conventions
• Ensure public API changes include docs and migration notes.

Testing policy
• Add/extend tests in the `__tests__/` folder with .test.ts suffix.
• Add/extend tests in the `__tests__/integration/` folder with .test.ts suffix.
• Cover:
• Core API clients and models (StreamClient, StreamChatClient, etc.)
• Utility functions (token creation, rate limiting)
Expand Down
229 changes: 229 additions & 0 deletions __tests__/docs-snippets/AGENTS.md

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions __tests__/docs-snippets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Docs snippet tests

Typed tests that run the server-side Node.js snippets of the chat docs (the pages of the Node sidebar,
`getstream.io/content/docs/_sidebars/[chat][node].json`) and of the platform docs (the pages of
`getstream.io/content/docs/_sidebars/[platform][default].json`) against a real Stream app.
Only fences labelled `Node.js` are in scope (docs fences must always have a label): they are fixed when they are
wrong. `JavaScript`, `React` and `React Native` fences (client-side code) and `Node.js` fences inside a `<disclosure label="... (no SDK)">` block (reference
implementations without the SDK) are out of scope and are neither tested nor changed.

```sh
# credentials: STREAM_API_KEY / STREAM_SECRET in the repo root .env (the node SDK test app, see .env-example)
yarn test:docs # all docs tests
yarn test:docs pages/chat/node/01-quick_start/01-backend_quickstart.test.ts # one file
yarn types:docs # typecheck the docs tests
yarn test:docs:sync # test regions == docs fences (no network)
yarn test:docs:sweep # remove docs-test leftovers older than 30 min, incl. channels, and turns off retention policies (DOCS_SWEEP_DRY_RUN=1 to list only, DOCS_SWEEP_MIN_AGE=0 for all)
yarn eslint __tests__/docs-snippets && npx prettier --check __tests__/docs-snippets # lint
```

These tests hit the network and are not part of `yarn test` (which runs `__tests__/integration`). Don't run them
at the same time as `yarn test`: both use the same app, and the drift check would see the other suite's changes.

## How to add tests?

Run the `docs-sync` Claude Code skill (`.claude/skills/docs-sync/SKILL.md`): `/docs-sync [path to the getstream.io checkout]`. It runs `yarn test:docs:sync` against that checkout, adds tests for new `Node.js` snippets, updates existing tests to docs changes (the docs win), and writes fixes for snippets that don't work back to the docs.

## Push credentials (optional)

`DOCS_PUSH_FIREBASE_CREDENTIALS` in the repo root `.env`: a Firebase service account JSON, on one line. Snippets
that need real push credentials run in `describe.skipIf(!hasFirebaseCredentials)` (`helpers/push.ts`), so the
suite passes without it (those tests are reported as skipped). `sampleFirebaseCredentials` (`helpers/push.ts`) is
the sample service account JSON from the platform push providers page, for snippets with a credentials
placeholder; when the API rejects it, the snippet needs real credentials and is skipped. Push and webhook tests only call the API and
check the response, or read the setting back (`getApp`, `listPushProviders`, push preferences, ...). They don't
check that notifications or webhook events arrive.

## Layout

```
pages/chat/<docs section>/<page>.test.ts # chat Node.js snippets, run with the server client
pages/platform/<docs section>/<page>.test.ts # platform Node.js snippets
helpers/ # server client, unique ids, cleanup registry, server-side helpers,
# waits, leak check, app snapshot, push credentials
env.ts # .env loading (fails without STREAM_API_KEY / STREAM_SECRET)
setup.ts # per-file leak check and app-settings drift check
global-setup.ts # end-of-run sweep: removes leftovers of files that failed while loading
sync/ # `yarn test:docs:sync` (docs fence <-> test region check)
maintenance/ # `yarn test:docs:sweep`
docs-snippets-todo.md # open API spec / generator and node SDK issues
AGENTS.md # test conventions and API quirks, read before working on a page
```

The path mirrors the docs path (relative to `content/docs`) without `/_default`, e.g.
`chat/_default/04-messages/01-send_message.md` maps to `pages/chat/04-messages/01-send_message.test.ts`,
`chat/node/01-quick_start/...` to `pages/chat/node/01-quick_start/...` and
`platform/02-identity-and-access/02-users.md` to `pages/platform/02-identity-and-access/02-users.test.ts`.

## Conventions

The test conventions and the API quirks found so far are in [`AGENTS.md`](AGENTS.md). Read it before working on a page.

## Snippet markers and docs sync

Each docs fence has exactly one marked region in a test. `yarn test:docs:sync` (no network) compares them, so later edits on either side fail until they agree again:

```ts
// #region snippet docs="chat/node/01-quick_start/01-backend_quickstart.md" heading="Syncing channels" tab="Node.js" index=1
// COPY: channelId="kung-fu", creatorId="4645"
const channel = client.chat.channel("messaging", channelId);

// create the channel and set created_by to user id 4645
await channel.getOrCreate({ data: { created_by_id: creatorId } });
// #endregion snippet
```

- **Fence key**:
- `docs` is the page path relative to `getstream.io/content/docs/` (`chat/...` or `platform/...`).
- `heading` is the text of the nearest heading above the fence.
- `tab` is the fence `label` (or `unlabelled`).
- `index` is 1-based among fences with the same heading text and tab (default 1).
- **COPY**: `name="literal"` pairs, applied to the region before comparing (identifier `name` becomes the docs literal). Don't use COPY variables as object keys or shorthand properties.
- **Comparison**: both sides are formatted with the same prettier options, so quote style and line wrapping don't matter, but any code or comment change does.
- **Coverage** (checked first): every `Node.js` fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the chat Node sidebar and the platform sidebar (`content/docs/_sidebars/[chat][node].json`, `[platform][default].json`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `JavaScript` / `React` / `React Native` fences and comment-only fences are ignored.
- **Labels**: every JS fence on those pages must be labelled `JavaScript`, `React`, `React Native` or `Node.js`; unlabelled or otherwise labelled JS fences fail.
- **Excluded pages**: `EXCLUDED_PAGES` in `sync/parse.ts` lists sidebar pages that are deliberately not validated, with a reason. Both checks skip them.
- **`DOCS_SYNC_WRITE=<page path> yarn test:docs:sync`** writes the regions of that page into the docs fences (docs prettier style). Use it to apply a fix to the docs. `DOCS_SYNC_WRITE=1` writes all pages.
- **`DOCS_DIR`** points at another docs checkout (default: `../getstream.io/content/docs` next to this repo). The check is skipped if the directory doesn't exist.
14 changes: 14 additions & 0 deletions __tests__/docs-snippets/docs-snippets-todo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Docs snippet tests: todo

Open issues found while testing the docs snippets, in two lists: [API spec / generator issues](#api-spec--generator-issues) (the OpenAPI spec is wrong, or the generator turns it into wrong node SDK code) and [Node SDK issues](#node-sdk-issues) (the hand-written node SDK code is wrong). The API itself works. Not fixed by the agents (no `src/` changes). Remove an entry once it is fixed.

## API spec / generator issues

- **06-app_and_channel_settings/03-chat_permission_policies** (node SDK generator): `UpdateChannelTypeRequest.grants` and `UpdateAppRequest.grants` are typed `Record<string, string[]>` without `| null`, while the spec marks them `nullable: true` and the docs reset a scope with `grants: null` (TS2322). The API accepts `null` and resets the grants to the defaults. The generator drops `nullable` everywhere (`src/gen/models/index.ts` has no `| null` at all, e.g. `pin_expires?: Date`): a generator change in `../chat` (`tools/openapi`). Not patched (the local `src/gen` patch was reverted on 2026-10-13), so `yarn types:docs` reports TS2322 on the "Granting permissions" Node.js #4 region until the generator emits `| null`. Reported: [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109).
- **05-features/06-campaign_api** (API spec): `updateCampaign` and `stopCampaign` are typed as returning `CampaignResponse`, but the API returns `{ campaign: CampaignResponse, ... }` (like `StartCampaignResponse`), so `response.campaign` is a TS2339 error and `response.name` etc. are undefined at runtime. The response schemas of `PUT /campaigns/{id}` and `POST /campaigns/{id}/stop`. Reported: [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109).
- **04-messages/08-unread_reminders** (API spec, to check): `CreateChannelTypeRequest` has no `reminders` field, while `UpdateChannelTypeRequest` has it, so a channel type can't be created with reminders on in one call from typed code. Check whether the spec (`CreateChannelType`) lacks it or the API doesn't accept it. Reported: [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109).
- **platform/02-identity-and-access/01-authentication** (node SDK generator): `UpdateAppRequest.revoke_tokens_issued_before` is typed `Date` without `| null`, so the docs' `updateApp({ revoke_tokens_issued_before: null })` ("Undoing the revoke", Node.js #2) fails TS2322 and `yarn types:docs` reports it. Same root cause as the `grants: null` entry above (the generator drops `nullable`); check that the spec marks the field nullable. Not run against the API (see Blockers detail). `UpdateUserPartialRequest.set` is `Record<string, any>`, so the user-level `set: { revoke_tokens_issued_before: null }` typechecks. Reported (as the generator `nullable` issue): [Slack thread](https://getstream.slack.com/archives/C07AL9Q0T2T/p1791403182844109).

## Node SDK issues

None open.
11 changes: 11 additions & 0 deletions __tests__/docs-snippets/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { config } from 'dotenv';
import path from 'node:path';

// The node SDK's test app, shared with the integration tests (repo root .env).
config({ path: path.join(__dirname, '../../.env'), quiet: true });

if (!process.env.STREAM_API_KEY || !process.env.STREAM_SECRET) {
throw new Error(
'Missing STREAM_API_KEY / STREAM_SECRET. Copy .env-example to .env in the repo root and fill it in.',
);
}
84 changes: 84 additions & 0 deletions __tests__/docs-snippets/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { config } from 'dotenv';
import path from 'node:path';
import { getServerClient } from './helpers/clients';
import { createdAtFromId, DOCS_TEST_PREFIX } from './helpers/ids';
import {
describeLeftovers,
findLeftovers,
removeLeftovers,
} from './helpers/leaks';
import type { Leftover } from './helpers/leaks';
import {
getRetentionPolicies,
restoreRetentionPolicies,
} from './helpers/retention';

// Safety net for the per-file leak check in setup.ts: a test file that throws while it
// is loaded (module-level setup or region) runs no hooks, so neither its cleanup nor the
// leak check runs. After the whole run, remove every docs-test resource created since the
// run started, put back the retention policies the run started with (they are app-level
// and delete old data), and fail the run if either was needed.

/** Server and local clocks can differ a little. */
const CLOCK_SKEW_MS = 5000;

const createdAt = (leftover: Leftover) => {
// Campaigns and polls are listed as `<id> (<name>)`; ids minted by `uniqueId` carry
// their creation time, anything else falls back to the server timestamp.
const fromId = createdAtFromId(leftover.id.split(' ')[0]);
if (fromId) return fromId;
return leftover.createdAt ? new Date(leftover.createdAt) : undefined;
};

const loadEnv = () => {
config({ path: path.join(__dirname, '../../.env'), quiet: true });
return Boolean(process.env.STREAM_API_KEY && process.env.STREAM_SECRET);
};

export default async function setup() {
const startedAt = Date.now();
const retentionPolicies = loadEnv()
? await getRetentionPolicies(getServerClient())
: undefined;

return async function teardown() {
if (!loadEnv()) return;
const client = getServerClient();

const restoredPolicies = retentionPolicies
? await restoreRetentionPolicies(client, retentionPolicies)
: [];

const { leftovers, errors } = await findLeftovers(client, {
// `includes`: guest user ids are rewritten to `guest-<uuid>-docs-test-...`.
matches: (id) => id.includes(DOCS_TEST_PREFIX),
channelsCreatedSince: new Date(startedAt - CLOCK_SKEW_MS),
});
if (errors.length) {
console.warn(
`[docs-snippets] end-of-run sweep could not scan:\n ${errors.join('\n ')}`,
);
}
const fromThisRun = leftovers.filter((leftover) => {
const time = createdAt(leftover);
return time !== undefined && time.getTime() >= startedAt - CLOCK_SKEW_MS;
});
const problems: string[] = [];
if (restoredPolicies.length) {
problems.push(
`[docs-snippets] DRIFT: this run changed retention policies (${restoredPolicies.join(', ')}). ` +
`They have now been restored to what the app had when the run started.`,
);
}
if (fromThisRun.length) {
const failed = await removeLeftovers(fromThisRun);
problems.push(
`[docs-snippets] LEAK: this run left resources behind that no per-file leak check ` +
`caught (usually a test file that failed while loading). They have now been removed` +
`${failed.length ? `, except ${failed.length} that failed - run \`yarn test:docs:sweep\`` : ''}:\n` +
describeLeftovers(fromThisRun),
);
}
if (problems.length) throw new Error(problems.join('\n'));
};
}
21 changes: 21 additions & 0 deletions __tests__/docs-snippets/helpers/channel-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { StreamClient, UpdateChannelTypeRequest } from '../../../index';

/**
* Updates a channel type with only the fields in `patch`. The node-sdk request requires
* `automod`, `automod_behavior` and `max_message_length`, so their current values are
* read and passed through unchanged. Call `waitForChannelTypePropagation()` after it.
*/
export const updateChannelType = async (
serverClient: StreamClient,
name: string,
patch: Partial<UpdateChannelTypeRequest>,
) => {
const current = await serverClient.chat.getChannelType({ name });
return serverClient.chat.updateChannelType({
name,
automod: current.automod,
automod_behavior: current.automod_behavior,
max_message_length: current.max_message_length,
...patch,
});
};
103 changes: 103 additions & 0 deletions __tests__/docs-snippets/helpers/cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { StreamClient } from '../../../index';
import { hardDeleteUsers } from './users';
import {
CHANNEL_TYPE_PROPAGATION_MS,
isChannelTypeMissing,
retry,
retryOnRateLimit,
waitForTask,
} from './wait';

/**
* Collects everything a test file creates and removes it in `afterAll`.
*
* ```ts
* const cleanup = new Cleanup(getServerClient());
* cleanup.users.push(userId);
* cleanup.add(() => serverClient.updateApp(originalSettings));
* afterAll(() => cleanup.run());
* ```
*
* Everything is attempted even if a step fails; failures are thrown together at the
* end so leftovers (especially channel types, which count against an app limit) don't
* pile up silently.
*/
export class Cleanup {
users: string[] = [];
/** Channel cids (`type:id`). */
channels: string[] = [];
channelTypes: string[] = [];
private custom: Array<() => Promise<unknown>> = [];

constructor(private serverClient: StreamClient) {}

/** Extra cleanup steps (restore app settings, delete blocklists, ...). Run first, in reverse order. */
add(fn: () => Promise<unknown>) {
this.custom.push(fn);
}

async run() {
const errors: unknown[] = [];
const attempt = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (error) {
errors.push(error);
}
};

for (const fn of [...this.custom].reverse()) await attempt(fn);
if (this.channels.length) await attempt(() => this.deleteChannels());
// A channel type can only be deleted once all of its channels are gone.
for (const type of this.channelTypes) {
await attempt(() => this.deleteChannelType(type));
}
if (this.users.length) {
await attempt(() => hardDeleteUsers(this.serverClient, this.users));
}

if (errors.length) {
throw new AggregateError(
errors,
'[docs-snippets] cleanup failed, see `errors`',
);
}
}

private async deleteChannelType(type: string) {
// "channels of that type exist": the channel delete task hasn't caught up yet.
// "does not exist": either already deleted (e.g. by the snippet) or, right after
// creation, this API node doesn't know the type yet - keep trying for a while and
// treat it as gone after that.
const startedAt = Date.now();
try {
await retry(
() => this.serverClient.chat.deleteChannelType({ name: type }),
{
timeout: 60000,
interval: 2000,
retryIf: (error) =>
error instanceof Error &&
(/channels of that type exist/.test(error.message) ||
(isChannelTypeMissing(error) &&
Date.now() - startedAt < CHANNEL_TYPE_PROPAGATION_MS + 5000)),
},
);
} catch (error) {
if (!isChannelTypeMissing(error)) throw error;
}
}

private async deleteChannels() {
// Server-side hard delete runs as a background task: wait for it so the channel
// types can be deleted afterwards.
const response = await retryOnRateLimit(() =>
this.serverClient.chat.deleteChannels({
cids: this.channels,
hard_delete: true,
}),
);
if (response.task_id)
await waitForTask(this.serverClient, response.task_id);
}
}
19 changes: 19 additions & 0 deletions __tests__/docs-snippets/helpers/clients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { StreamClient } from '../../../index';
import { trackChannelTypeChanges } from './wait';

const apiKey = () => process.env.STREAM_API_KEY as string;
const apiSecret = () => process.env.STREAM_SECRET as string;

/**
* Server-side client (API key + secret), the `client` of the docs snippets. Also used for
* setup, cleanup and assertions. The default 3s timeout is too short for some endpoints.
* Its channel type changes are recorded for `waitForChannelTypePropagation()`.
*/
export const getServerClient = () =>
trackChannelTypeChanges(
new StreamClient(apiKey(), apiSecret(), { timeout: 30000 }),
);

/** User token for `userId`, signed with the API secret. */
export const createUserToken = (userId: string) =>
getServerClient().generateUserToken({ user_id: userId });
Loading
Loading