diff --git a/.github/workflows/docs-snippets.yml b/.github/workflows/docs-snippets.yml new file mode 100644 index 000000000..80704e5bc --- /dev/null +++ b/.github/workflows/docs-snippets.yml @@ -0,0 +1,217 @@ +name: Docs snippets + +# Live tests of the JavaScript docs code samples (docs-snippets/README.md). Separate from run-tests.yml on purpose: this +# suite calls the real PubNub service and is never part of npm test, ci:node or ci:web. +# +# No workflow-level paths filter: a required check behind a paths filter stays pending. The `changes` job decides instead, +# and the `gate` job is the one to require in branch protection. + +on: + pull_request: + push: + branches: [master] + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + changes: + name: Relevant changes + runs-on: + group: organization/Default + outputs: + changed: ${{ steps.diff.outputs.changed }} + live: ${{ steps.diff.outputs.live }} + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Decide with git diff + id: diff + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE: ${{ github.event.before }} + FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} + run: | + changed=false + if [ "$EVENT" = "schedule" ] || [ "$EVENT" = "workflow_dispatch" ]; then + changed=true + else + base="$PR_BASE" + [ "$EVENT" = "push" ] && base="$PUSH_BEFORE" + if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then + changed=true + elif git diff --name-only "$base" HEAD | grep -qE '^(docs-snippets/|src/|lib/|package\.json$|package-lock\.json$|\.github/workflows/docs-snippets\.yml$)'; then + changed=true + fi + fi + live=$changed + if [ "$FORK" = "true" ]; then + live=false + [ "$changed" = "true" ] && echo "live shards skipped: fork PR without secrets" >> "$GITHUB_STEP_SUMMARY" + fi + echo "changed=$changed" >> "$GITHUB_OUTPUT" + echo "live=$live" >> "$GITHUB_OUTPUT" + echo "relevant changes: $changed, live shards: $live" + + dry: + name: Dry run, types and manifest + needs: [changes] + if: needs.changes.outputs.changed == 'true' + runs-on: + group: organization/Default + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Setup NodeJS 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Build the SDK for Node + run: npm run build:node + - name: Dry run (no network) + run: npm run test:docs-snippets:dry + - name: Type check the suite and the browser samples + run: npm run test:docs-snippets:types + - name: Check the coverage manifest + run: npm run docs-snippets:manifest -- --check + + live: + name: Live shards (open, pam, datasync) + needs: [changes, dry] + if: needs.changes.outputs.live == 'true' + runs-on: + group: organization/Default + # The three shards run one after another in this one job, never in parallel. The pam and datasync roles may share one + # keyset, and the pam shard opens it with an application-level grant for a moment. Run in parallel, that grant and the + # janitor's revoke would break the other shard. + timeout-minutes: 35 + # One live job at a time across the repository, so two workflow runs (a pull request and master, say) never share the + # keysets either. A newer waiting run replaces an older waiting one, a running one is never cancelled. + concurrency: + group: docs-snippets-live + cancel-in-progress: false + # Keys and secrets. The one place that maps CI secrets onto the env names the suite reads (docs-snippets/README.md, "Keys in + # CI"). They are set on the steps that run the suite only, never on the job, so `npm ci` (install scripts of + # dependencies) and the build never see them. + env: + DOCS_SNIPPETS_DS_KEYSET_ID: ${{ vars.SDK_DOCS_DS_KEYSET_ID }} + # Nightly: the CI keysets must have these features, so their skips fail the run. + DOCS_SNIPPETS_REQUIRE_FEATURES: ${{ github.event_name == 'schedule' && 'datasync-events,push-notifications' || '' }} + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Setup NodeJS 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Build the SDK for Node + run: npm run build:node + # Each shard runs even when an earlier one failed (success() || failure()), but not after a cancel. Each gets the secret key + # of its own role only. The janitor step after them needs every role, so it keeps all of them. + - name: Shard open + id: open + if: success() || failure() + env: + SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_OPEN_SUBSCRIBE_KEY }} + PUBLISH_KEY: ${{ secrets.SDK_DOCS_OPEN_PUBLISH_KEY }} + run: npm run test:docs-snippets -- --shard open + - name: Keep the open report + if: (success() || failure()) && steps.open.outcome != 'skipped' + run: mkdir -p docs-snippets/report/open && mv docs-snippets/report/run.json docs-snippets/report/summary.md docs-snippets/report/open/ 2>/dev/null || true + - name: Shard pam + id: pam + if: success() || failure() + env: + SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_OPEN_SUBSCRIBE_KEY }} + PUBLISH_KEY: ${{ secrets.SDK_DOCS_OPEN_PUBLISH_KEY }} + PAM_SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_PAM_SUBSCRIBE_KEY }} + PAM_PUBLISH_KEY: ${{ secrets.SDK_DOCS_PAM_PUBLISH_KEY }} + PAM_SECRET_KEY: ${{ secrets.SDK_DOCS_PAM_SECRET_KEY }} + run: npm run test:docs-snippets -- --shard pam + - name: Keep the pam report + if: (success() || failure()) && steps.pam.outcome != 'skipped' + run: mkdir -p docs-snippets/report/pam && mv docs-snippets/report/run.json docs-snippets/report/summary.md docs-snippets/report/pam/ 2>/dev/null || true + - name: Shard datasync + id: datasync + if: success() || failure() + env: + SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_OPEN_SUBSCRIBE_KEY }} + PUBLISH_KEY: ${{ secrets.SDK_DOCS_OPEN_PUBLISH_KEY }} + DS_SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_DS_SUBSCRIBE_KEY }} + DS_PUBLISH_KEY: ${{ secrets.SDK_DOCS_DS_PUBLISH_KEY }} + DS_SECRET_KEY: ${{ secrets.SDK_DOCS_DS_SECRET_KEY }} + # Only this shard gets the Admin API key: it sets up the DataSync classes before the tests. + PUBNUB_ADMIN_API_KEY: ${{ secrets.SDK_DOCS_ADMIN_API_KEY }} + run: npm run test:docs-snippets -- --shard datasync + - name: Keep the datasync report + if: (success() || failure()) && steps.datasync.outcome != 'skipped' + run: mkdir -p docs-snippets/report/datasync && mv docs-snippets/report/run.json docs-snippets/report/summary.md docs-snippets/report/datasync/ 2>/dev/null || true + # One block on the run page with a row per snippet id (passed, FAILED, skipped, not run, type checked only). It reads the + # shard reports and coverage.json, needs no secrets, and runs after a failure or a cancel too. + - name: Result per snippet id + if: always() + run: node docs-snippets/harness/snippet-summary.cjs docs-snippets/report/open docs-snippets/report/pam docs-snippets/report/datasync + # After a failure or a cancel: revoke the application-level Access Manager grants and remove leftovers, so a run that + # died between a grant and its revoke never leaves a keyset open until the next run. + - name: Revoke grants and clean up + if: cancelled() || failure() + env: + SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_OPEN_SUBSCRIBE_KEY }} + PUBLISH_KEY: ${{ secrets.SDK_DOCS_OPEN_PUBLISH_KEY }} + PAM_SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_PAM_SUBSCRIBE_KEY }} + PAM_PUBLISH_KEY: ${{ secrets.SDK_DOCS_PAM_PUBLISH_KEY }} + PAM_SECRET_KEY: ${{ secrets.SDK_DOCS_PAM_SECRET_KEY }} + DS_SUBSCRIBE_KEY: ${{ secrets.SDK_DOCS_DS_SUBSCRIBE_KEY }} + DS_PUBLISH_KEY: ${{ secrets.SDK_DOCS_DS_PUBLISH_KEY }} + DS_SECRET_KEY: ${{ secrets.SDK_DOCS_DS_SECRET_KEY }} + run: npm run test:docs-snippets:janitor + - name: Upload the reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: docs-snippets-reports + path: docs-snippets/report/ + if-no-files-found: warn + + gate: + name: Docs snippets + if: always() + needs: [changes, dry, live] + runs-on: + group: organization/Default + steps: + - name: Decide + env: + RESULTS: ${{ needs.changes.result }} ${{ needs.dry.result }} ${{ needs.live.result }} + CHANGED: ${{ needs.changes.outputs.changed }} + run: | + echo "changes, dry, live: $RESULTS (relevant changes: $CHANGED)" + for result in $RESULTS; do + if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then + echo "a needed job ended with $result" + exit 1 + fi + done + echo "docs snippets gate passed" diff --git a/.gitignore b/.gitignore index 77ff72cfd..5d928bc9c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,11 @@ test/specs release-artifacts-* .vscode + +# Live docs snippet suite (docs-snippets/README.md) # +#################################################### +docs-snippets/.run/ +docs-snippets/report/ +.env +.env.* +!docs-snippets/.env.example diff --git a/docs-snippets/.env.example b/docs-snippets/.env.example new file mode 100644 index 000000000..cb0289edc --- /dev/null +++ b/docs-snippets/.env.example @@ -0,0 +1,25 @@ +# Keys for the live docs snippet tests (docs-snippets/README.md, "Use your own keys"). +# Copy this file to docs-snippets/.env.local and fill in the values. Git ignores .env.local. +# Real environment variables win over this file. Leave a role empty to skip its steps (missing-env:). + +# open: a keyset without Access Manager +SUBSCRIBE_KEY= +PUBLISH_KEY= + +# pam: a keyset with Access Manager and token revocation +PAM_SUBSCRIBE_KEY= +PAM_PUBLISH_KEY= +PAM_SECRET_KEY= + +# datasync: a keyset with Access Manager and DataSync. It may be the same keyset as pam. +DS_SUBSCRIBE_KEY= +DS_PUBLISH_KEY= +DS_SECRET_KEY= + +# Optional. A Service Integration key with DataSync read and write on the datasync keyset. It creates the DataSync classes. +PUBNUB_ADMIN_API_KEY= + +# Optional, not secret. Admin Portal keyset ids, so the preflight can read each keyset's features through the Admin API. +DOCS_SNIPPETS_OPEN_KEYSET_ID= +DOCS_SNIPPETS_PAM_KEYSET_ID= +DOCS_SNIPPETS_DS_KEYSET_ID= diff --git a/docs-snippets/.mocharc.cjs b/docs-snippets/.mocharc.cjs new file mode 100644 index 000000000..e817492b3 --- /dev/null +++ b/docs-snippets/.mocharc.cjs @@ -0,0 +1,15 @@ +'use strict'; +/** + * Mocha options of the live docs snippet suite. harness/cli.ts reads them and adds the staged spec file, which only exists + * after staging (docs-snippets/.run//harness/mocha-entry.ts). This file is separate from the SDK's root .mocharc.json + * on purpose: the suite never loads test/setup-why.ts (it restores nock) and the root spec never picks up the suite. + * + * retries 0: a retry would hide a flaky docs sample, waits are explicit (harness/wait.ts). bail false: one failing step must + * not hide the others. + */ +module.exports = { + reporter: require('node:path').join(__dirname, 'harness', 'reporter.cjs'), + retries: 0, + timeout: 30000, + bail: false, +}; diff --git a/docs-snippets/README.md b/docs-snippets/README.md new file mode 100644 index 000000000..c443447e7 --- /dev/null +++ b/docs-snippets/README.md @@ -0,0 +1,169 @@ +# Live docs snippet tests + +These tests run the code samples of the JavaScript SDK (software development kit) docs against the real PubNub service and check that each sample does what its docs page says. + +## What only these tests catch + +A mocked test never runs the docs code and never talks to the service, so it cannot find a wrong docs sample. + +## Why they do not duplicate the integration tests + +- A test here checks only what the docs page claims, through the service. It does not check request encoding, retries or parsing. `test/integration` owns that. +- No step extends a sample to cover more parameters. A passing step means the documented behavior held, not that every option of the method works. +- The code readers see stays simple. All test code sits outside the snippet markers or on `snippet.hide` lines, and a marker lint enforces it. + +## Tech stack + +The suite adds no dependency. It runs on the Mocha 10.4 and tsx that the existing Node tests use, and checks types with the repository's TypeScript. It has its own Mocha config (`.mocharc.cjs`), its own tsconfigs and no nock, the HTTP mocking library of the integration tests. + +## What a run does + +| Stage | What happens | +| :--- | :--- | +| 1. Load keys | The loader reads the env names below and prints "set" or "missing" per role | +| 2. Stage | The command line tool, [`harness/cli.ts`](harness/cli.ts), copies the suite to `docs-snippets/.run//` and adds the run suffix to resource names | +| 3. Set up DataSync | Only when the run includes the `datasync` role. The setup creates missing classes | +| 4. Preflight | Checks that each keyset has the features its role needs, and names every missing one | +| 5. Steps | Each step runs its docs snippet, then asserts the effect through the service | +| 6. Clean up | The janitor (the cleanup routine) deletes what the run created and proves that no Access Manager grant is left open | +| 7. Report | The reporter writes `report/run.json` and `report/summary.md`, then the run policy decides the exit code | + +A shard is the set of steps of one role. CI, the GitHub Actions workflow [`docs-snippets.yml`](../.github/workflows/docs-snippets.yml), runs the three shards one after another in one job, and each shard's report goes to `report//`. + +## Run the tests locally + +You need Node.js 22 or later and your own PubNub keysets, as "Use your own keys" describes. Then, from the repository root: + +```bash +npm ci +npm run build:node # the tests use lib/node/index.js of this checkout +cp docs-snippets/.env.example docs-snippets/.env.local # then fill in your keys +npm run test:docs-snippets # everything +``` + +| Command | What it does | +| :--- | :--- | +| `npm run test:docs-snippets` | Stages the suite, sets up the DataSync classes when the run includes the `datasync` role, then runs every step live | +| `npm run test:docs-snippets -- --shard ` | Runs one role: `open` (about 8 min), `pam` (about 1.5 min) or `datasync` (about 3.5 min) | +| `npm run test:docs-snippets -- --grep ` | Runs the steps whose title matches, for example `--grep publishBasicUsage`. Each title carries its snippet ids. The steps a selected step needs run first, and the report lists them as prerequisites. Refused in CI | +| `npm run test:docs-snippets:dry` | No network. Checks the registry, staging, the marker lint, the static mapping, the manifest, the selection, the run policy and the reporter | +| `npm run test:docs-snippets:types` | Type checks the suite (`tsconfig.live.json`) and the browser samples (`tsconfig.browser.json`) | +| `npm run docs-snippets:manifest` | Writes `coverage.json`. With `-- --check` it verifies it instead | +| `npm run docs-snippets:provision` | Creates missing DataSync classes and updates drifted ones. With `-- --check` it shows the difference and writes nothing | +| `npm run test:docs-snippets:janitor` | Removes leftovers of runs older than six hours and revokes the application-level Access Manager grants | + +Rebuild with `npm run build:node` after a change under `src/`, otherwise the suite tests the previous build. If `pam` and `datasync` point at the same keyset, do not run those two shards at the same time. + +Each run writes two files to `docs-snippets/report/`. `run.json` has one record per step with its snippet ids, page, role, status, skip reason, duration and assertion count, plus the SDK version and commit. `summary.md` has the counts, failures, skips, time per phase and the slowest steps. In GitHub Actions the summary also goes to the job summary. + +## How a test runs the exact docs code + +A test is a `step()` in a page module that has the same base name as its docs page, for example [`api-reference/publish.ts`](api-reference/publish.ts) for the publish page. The docs embed the code between the markers from this file, so the test and the docs page show the same code. + +### Checks that keep the tests honest + +| Check | Rule | +| :--- | :--- | +| The snippet ran | In the staged copy every snippet start and end line records itself. A step fails when one of its snippets did not reach its `snippet.end` line | +| It asserted something | A step fails when it recorded zero assertions after its snippet started. Fixture setup does not count | +| Static mapping | Every marker id belongs to exactly one step or stub, every step's `snippets` list equals the markers in its body, and no id appears twice | +| Mutation | `DOCS_SNIPPETS_MUTATE=` throws at the start marker in the staged copy. That step must fail. Refused in CI | +| Same lines | Staging rewrites only registered names and never moves a line, so the line number of a failure matches the source file | + +## Keysets, roles and secrets + +The suite reads one fixed set of env names, whatever fills them. + +| Role | Env names | Used for | +| :--- | :--- | :--- | +| `open` | `SUBSCRIBE_KEY`, `PUBLISH_KEY` | Publish, subscribe, presence, history, message actions, App Context, files, push, channel groups, encryption, misc | +| `pam` | `PAM_SUBSCRIBE_KEY`, `PAM_PUBLISH_KEY`, `PAM_SECRET_KEY` | Access Manager v2 and v3 samples, `publishHandleErrors` and other 403 branches | +| `datasync` | `DS_SUBSCRIBE_KEY`, `DS_PUBLISH_KEY`, `DS_SECRET_KEY` | DataSync samples, including the ones that also need Access Manager | +| admin | `PUBNUB_ADMIN_API_KEY` | DataSync class setup before the tests, and a read-only check of keyset features | + +### Use your own keys + +1. In the [Admin Portal](https://admin.pubnub.com), create the keysets of "Features each keyset needs" below. One keyset can serve both `pam` and `datasync`. +2. Copy [`docs-snippets/.env.example`](.env.example) to `docs-snippets/.env.local` and fill in the keys. Git ignores `.env.local`. +3. Run `npm run test:docs-snippets -- --shard open` first. It needs only the open keyset. + +Leave a role empty and its steps skip as `missing-env:`. You can also keep the file elsewhere and set `DOCS_SNIPPETS_ENV_FILE=`, or export the env names in your shell. Real environment variables always win over the file. The file may also use the older `PN_DOCS_*` names, which the loader maps onto the names above. + +The loader reads the file in memory and prints only "set" or "missing" per role. It never prints a key or part of one, and the reporter redacts every file before it writes it. + +### Keys in CI + +The workflow sets the same names from repository secrets in one `env:` block of the `live` job: + +| Secret | Env name | +| :--- | :--- | +| `SDK_DOCS_OPEN_SUBSCRIBE_KEY`, `SDK_DOCS_OPEN_PUBLISH_KEY` | `SUBSCRIBE_KEY`, `PUBLISH_KEY` | +| `SDK_DOCS_PAM_SUBSCRIBE_KEY`, `SDK_DOCS_PAM_PUBLISH_KEY`, `SDK_DOCS_PAM_SECRET_KEY` | `PAM_*` | +| `SDK_DOCS_DS_SUBSCRIBE_KEY`, `SDK_DOCS_DS_PUBLISH_KEY`, `SDK_DOCS_DS_SECRET_KEY` | `DS_*` | +| `SDK_DOCS_ADMIN_API_KEY` (only the `datasync` shard gets it) | `PUBNUB_ADMIN_API_KEY` | + +Optional and not secret: `DOCS_SNIPPETS_OPEN_KEYSET_ID`, `DOCS_SNIPPETS_PAM_KEYSET_ID` and `DOCS_SNIPPETS_DS_KEYSET_ID` (the workflow reads `vars.SDK_DOCS_DS_KEYSET_ID`). With the admin key the preflight reads each keyset's feature configuration through the Admin API, after it confirms that the keyset of that id has the role's subscribe key. + +Give this suite keysets of its own, not the keysets of `run-tests.yml`. Some Access Manager v2 samples grant on the whole keyset for a moment. The suite closes every grant when its step ends and checks before and after the run that a client without a token is refused, but a test that runs in parallel on the same keyset could still see the grant. + +### Features each keyset needs + +The suite never changes a keyset. A preflight names every missing feature in one message, for example "Presence is off on the open keyset". + +| Role | Features | +| :--- | :--- | +| `open` | Presence with tracking on all channels (pattern `*`), Message Persistence with Delete-From-History, App Context with user, channel and membership events on and referential integrity off, Files, Mobile Push Notifications (no credentials needed), Stream Controller with wildcard subscribe. Access Manager off | +| `pam` | Access Manager with token revocation, Message Persistence, Presence | +| `datasync` | Access Manager, DataSync, the classes `product` v1 and `ProductOwner` v1 with a `public` projection (the setup creates them), DataSync event rules for `User`, `Channel`, `Membership`, `product` and `ProductOwner` v1 (Admin Portal only, DataSync tab, "Select entities to sync") | + +### DataSync classes + +[`provision/datasync-classes.json`](provision/datasync-classes.json) defines the classes. Every live run that includes the `datasync` role sets them up first: + +- It creates missing classes and updates classes whose mutable fields drifted. It never deletes a class. +- A class whose immutable fields differ (relationship cardinality or entity sides) fails the run with the difference, because only a person may recreate it. +- A second run reports every class as unchanged and writes nothing. +- It touches only the classes of that file. Classes of other tests on a shared keyset stay as they are. +- Without `PUBNUB_ADMIN_API_KEY` it checks through the SDK, and a missing class fails the run with its name. + +## Skips + +Every skip prints its reason, and the report counts it. [`harness/skips.ts`](harness/skips.ts) holds the closed list of reasons. + +| Reason | Meaning | Effect in CI | +| :--- | :--- | :--- | +| `browser-only` | Needs a browser (File API, `toFile`, `toBlob`) | Allowed | +| `react-native` | Needs React Native | Allowed | +| `needs-real-device` | Needs a device | Allowed | +| `needs-feature:push-notifications` | The keyset has no Mobile Push add-on | Allowed, fatal when `DOCS_SNIPPETS_REQUIRE_FEATURES` names it (nightly) | +| `needs-feature:datasync-events` | DataSync event rules are off (Admin Portal only) | Allowed, fatal when `DOCS_SNIPPETS_REQUIRE_FEATURES` names it (nightly) | +| `missing-env:` | The role's keys are not set | Fatal | +| `blocked-by:` | A step this one needs did not pass | Counts against `DOCS_SNIPPETS_SKIP_BUDGET` (0 in CI) | + +`DOCS_SNIPPETS_STRICT=1` makes every skip fatal. + +The browser, React and React Native sources in `quickstarts/` stay here because the docs embed them. `tsconfig.browser.json` type checks the browser ones. Nothing compiles the React and React Native ones here, because the SDK has no React or React Native dependencies, so these snippets cannot be run in this repository. The browser ones are type checked only, because this repository has no browser to run them in. + +## Control variables + +| Variable | Effect | +| :--- | :--- | +| `DOCS_SNIPPETS_ENV_FILE` | Env file to read keys from. Default: `docs-snippets/.env.local` | +| `DOCS_SNIPPETS_SHARD` | Same as `--shard` | +| `DOCS_SNIPPETS_ONLY` | Comma-separated step ids, like `--grep` with exact ids. Refused in CI | +| `DOCS_SNIPPETS_MUTATE` | See "Checks that keep the tests honest". Refused in CI | +| `DOCS_SNIPPETS_INJECT_FAILURE` | Makes a step fail with an SDK-style 403 after it ran. Refused in CI | +| `DOCS_SNIPPETS_SKIP_BUDGET` | Most `blocked-by` skips allowed. Default 0 in CI, unlimited locally | +| `DOCS_SNIPPETS_REQUIRE_FEATURES` | Comma-separated features whose `needs-feature` skips fail the run | +| `DOCS_SNIPPETS_STRICT` | `1` makes every skip fatal | +| `DOCS_SNIPPETS_LOG_LEVEL` | SDK log level of the samples. `trace` and `debug` are refused in CI, because the SDK logs its configuration at those levels | +| `DOCS_SNIPPETS_VERBOSE` | `1` prints the console output of every step | +| `DOCS_SNIPPETS_RUN_ID` | Fixed run id. In CI it is derived from the time, `GITHUB_RUN_ID`, `GITHUB_RUN_ATTEMPT` and the shard | +| `DOCS_SNIPPETS_DOCS_DIR` | Docs checkout for the manifest's docs id report. Default: a `documentation` folder next to this repository | + +## Add a snippet + +1. Write the docs code inside a `step()` of the page module, between `// snippet.` and `// snippet.end`. +2. Register new resource names in `fixtures/names.ts`. +3. Assert the effect the page claims, outside the markers. Use a second client or a read back, not "it did not throw". +4. Run `npm run test:docs-snippets:dry`, then `npm run docs-snippets:manifest`, then `npm run test:docs-snippets -- --grep `. diff --git a/docs-snippets/api-reference/access-manager-legacy.ts b/docs-snippets/api-reference/access-manager-legacy.ts new file mode 100644 index 000000000..91d2900c6 --- /dev/null +++ b/docs-snippets/api-reference/access-manager-legacy.ts @@ -0,0 +1,263 @@ +import assert from '../harness/assert.js'; +import type PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { rejectsValidation } from '../harness/assertions.js'; +import { granted } from './access-manager.js'; + +/** + * docs/sdks/javascript/api-reference/access-manager-legacy.md (`grantToken` with `spaces` and `users`) + * + * Secured keyset, `world.server`. The SDK still maps the deprecated terms to the current ones, so a `spaces` + * grant is readable in the parsed token as `channels`. The four samples of the page were fenced blocks, so + * their snippet ids are new. The mixing rules of the deprecated scopes are asserted without sending anything. + * + * Phase P8 (legacy pages). Sneaker Drop role: the staff server issues tokens with the old vocabulary + * (`spaces`, `users`, `uuids`, `authorized_uuid`) that older shop code still sends. + * + * Proves: each snippet gets a token from the service, and the parsed token carries the deprecated scopes under + * their current names (`spaces` becomes `channels`, `users` stays `users`). The checks cover the alias parameter + * and the client-side mixing errors. + * Does not prove: that a deprecated token authorizes real traffic. No client uses these tokens, and the token + * format is covered by access-manager.ts. The mixing errors are validation errors raised before any request. + */ +export default page('api-reference/access-manager-legacy', 'P8', (world) => { + const secured = { keyset: 'pam' } as const; + + /** + * The public `grantToken` overloads do not accept `spaces` (live finding). The SDK + * exports `ObjectsGrantTokenParameters` for the deprecated vocabulary, so the snippets type-check against it. + */ + interface LegacyGrantClient { + grantToken(parameters: PubNub.PAM.ObjectsGrantTokenParameters): Promise; + parseToken: PubNub['parseToken']; + } + const legacy = (): LegacyGrantClient => world.server as unknown as LegacyGrantClient; + + // Proves a `spaces` grant round-trips: the parsed token lists the space under `channels`. The user id is + // asserted with the run suffix because the staging rewrite suffixes `my-authorized-userId`. The client is + // bound outside the markers through `legacy()` so the snippet can use the deprecated shape. + step('grantTokenSpacesBasicUsage', { ...secured, proves: 'A grant that uses the deprecated spaces scope returns a token whose parsed form lists the space under channels.' }, async () => { + const pubnub = legacy(); + // snippet.grantTokenSpacesBasicUsage + try { + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: "my-authorized-userId", + resources: { + spaces: { + "my-space": { + read: true, + }, + }, + }, + }); + // snippet.hide + world.keep('token', token); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const token = world.take('token'); + const parsed = pubnub.parseToken(token); + assert.equal(parsed?.authorized_uuid, world.n('my-authorized-userId')); + assert.deepEqual(granted(parsed?.resources?.channels?.['my-space']), { read: true }); + }); + + // Mixed `spaces` and `users` resources with different permission sets. Asserts one space and one user entry + // in the parsed token (`users` keeps its name, `get` and `update` survive). + step('grantTokenSpacesVariousResources', { ...secured, proves: 'A token with mixed spaces and users resources keeps one space entry and one user entry with their permissions.' }, async () => { + const pubnub = legacy(); + // snippet.grantTokenSpacesVariousResources + try { + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: "my-authorized-userId", + resources: { + spaces: { + "space-a": { + read: true, + }, + "space-b": { + read: true, + write: true, + }, + "space-c": { + read: true, + write: true, + }, + "space-d": { + read: true, + write: true, + }, + }, + users: { + "userId-c": { + get: true, + }, + "userId-d": { + get: true, + update: true, + }, + }, + }, + }); + // snippet.hide + world.keep('token', token); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const token = world.take('token'); + const parsed = pubnub.parseToken(token); + assert.deepEqual(granted(parsed?.resources?.channels?.['space-b']), { read: true, write: true }); + assert.deepEqual(granted(parsed?.resources?.users?.['userId-d']), { get: true, update: true }); + }); + + // Pattern-only grant. The regular expression key must appear unchanged under `patterns.channels`. + step('grantTokenSpacesRegEx', { ...secured, proves: 'A pattern-only grant with the deprecated spaces scope keeps the regular expression unchanged under patterns.channels.' }, async () => { + const pubnub = legacy(); + // snippet.grantTokenSpacesRegEx + try { + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: "my-authorized-userId", + patterns: { + spaces: { + "^space-[A-Za-z0-9]$": { + read: true, + }, + }, + }, + }); + // snippet.hide + world.keep('token', token); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const token = world.take('token'); + const parsed = pubnub.parseToken(token); + assert.deepEqual(granted(parsed?.patterns?.channels?.['^space-[A-Za-z0-9]$']), { read: true }); + }); + + // Resources and patterns in one token. Checks both sections of the parsed token survive side by side. + step('grantTokenSpacesRegExAndResources', { ...secured, proves: 'Resources and patterns in one deprecated-scope grant both survive in the parsed token.' }, async () => { + const pubnub = legacy(); + // snippet.grantTokenSpacesRegExAndResources + try { + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: "my-authorized-userId", + resources: { + spaces: { + "space-a": { + read: true, + }, + "space-b": { + read: true, + write: true, + }, + "space-c": { + read: true, + write: true, + }, + "space-d": { + read: true, + write: true, + }, + }, + users: { + "userId-c": { + get: true, + }, + "userId-d": { + get: true, + update: true, + }, + }, + }, + patterns: { + spaces: { + "^space-[A-Za-z0-9]$": { + read: true + }, + }, + }, + }); + // snippet.hide + world.keep('token', token); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const token = world.take('token'); + const parsed = pubnub.parseToken(token); + assert.deepEqual(granted(parsed?.resources?.channels?.['space-a']), { read: true }); + assert.deepEqual(granted(parsed?.patterns?.channels?.['^space-[A-Za-z0-9]$']), { read: true }); + }); + + // Deprecated parameter and scope sections of the page (prose, no snippets). + // `authorized_uuid` and `authorizedUserId` must produce the same token owner, and giving both is a validation + // error. The cast to `never` bypasses the type guard that forbids the combination, on purpose. + check('accessManagerLegacyAuthorizedUuidAlias', { ...secured, proves: 'authorized_uuid and authorizedUserId produce the same token owner, and passing both is a validation error.' }, async () => { + const server = world.server; + const user = world.n('my-authorized-userId'); + const resources = { channels: { 'legacy-alias': { read: true } } }; + const viaAlias = await server.grantToken({ ttl: 15, authorized_uuid: user, resources }); + const viaCurrent = await server.grantToken({ ttl: 15, authorizedUserId: user, resources }); + assert.equal(server.parseToken(viaAlias)?.authorized_uuid, user); + assert.equal(server.parseToken(viaCurrent)?.authorized_uuid, user); + await rejectsValidation( + server.grantToken({ ttl: 15, authorizedUserId: user, authorized_uuid: user, resources } as never), + /Cannot mix `authorizedUserId` with `authorized_uuid`/, + ); + }); + + // The `uuids` scope is the old name of `users`. The parsed token keeps it under `uuids`, for resources and patterns. + check('accessManagerLegacyUuidsScope', { ...secured, proves: 'The uuids scope is the old name of users and the parsed token keeps it under uuids for resources and patterns.' }, async () => { + const server = world.server; + const token = await server.grantToken({ + ttl: 15, + authorizedUserId: world.n('my-authorized-userId'), + resources: { uuids: { 'uuid-1': { get: true, update: true, delete: true } } }, + patterns: { uuids: { 'uuid-.*': { get: true } } }, + }); + const parsed = server.parseToken(token); + assert.deepEqual(granted(parsed?.resources?.uuids?.['uuid-1']), { get: true, update: true, delete: true }); + assert.deepEqual(granted(parsed?.patterns?.uuids?.['uuid-.*']), { get: true }); + }); + + // The SDK refuses to mix old and new scope names in one grant. These are client-side validation errors, so no + // request leaves the process and the secured keyset sees no traffic. + check('accessManagerLegacyMixingErrors', { ...secured, proves: 'Mixing old and new scope names in one grant is refused locally with a validation error and sends no request.' }, async () => { + const server = world.server; + const ttl = 15; + // users and uuids: the check spans resources and patterns together. + await rejectsValidation( + server.grantToken({ + ttl, + resources: { users: { a: { get: true } } }, + patterns: { uuids: { 'a.*': { get: true } } }, + }), + /Cannot mix `users` with `uuids`/, + ); + await rejectsValidation( + server.grantToken({ + ttl, + resources: { spaces: { a: { read: true } }, channels: { b: { read: true } } }, + } as never), + /Cannot mix `spaces` with `channels`/, + ); + await rejectsValidation( + server.grantToken({ + ttl, + resources: { spaces: { a: { read: true } }, uuids: { b: { get: true } } }, + } as never), + /Cannot mix `spaces` with `uuids`/, + ); + }); +}); diff --git a/docs-snippets/api-reference/access-manager-v2.ts b/docs-snippets/api-reference/access-manager-v2.ts new file mode 100644 index 000000000..545c870f6 --- /dev/null +++ b/docs-snippets/api-reference/access-manager-v2.ts @@ -0,0 +1,276 @@ +import assert from '../harness/assert.js'; +import type PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { eventually, expectStatus } from '../harness/wait.js'; +import { rejectsValidation } from '../harness/assertions.js'; +import { revokeApplicationGrant } from '../harness/revoke.js'; + +/** + * docs/sdks/javascript/api-reference/access-manager-v2.md (Access Manager v2 `grant`) + * + * Secured keyset, `world.server` (secret key). The auth keys (`my_authkey`, `key1`, `my-auth-key`) and the + * channel `my_channel` are registered names, so every grant lands on this run's own resources. The hardcoded + * samples of the page get new snippet ids (they were fenced blocks before), `grantWithCategories` keeps the id + * the page embeds today. + * + * Effect checks use clients with an `authKey`. The application level sample would open the whole keyset, so it + * runs last (P9) and a `finally` revokes it again. + * + * Phase P1 for everything except the application level grant. Sneaker Drop role: the staff server hands out + * Access Manager v2 permissions to shop clients by auth key (the older model, before tokens in access-manager.ts). + * + * Proves: the grant response carries the permissions the snippet asked for (levels `user`, `uuid`, `category`, + * `channel`, `subkey`) and, where it is reliable, that a client with the auth key can really publish. + * Does not prove: downgrades or revokes take effect on a client. Access Manager (v2) caches allows, so a + * downgrade stays visible only in the response. Grants carry a ttl and are not revoked one by one, only the + * application level grant is (it would expose the whole keyset). The others sit on run-suffixed names. + */ +export default page('api-reference/access-manager-v2', 'P1', (world) => { + const secured = { keyset: 'pam' } as const; + /** Loose view of a grant response. The union in the types is wide, the assertions read plain fields. */ + const loose = (response: unknown): Record => response as Record; + + /** A client that sends `authKey` with every request. */ + const withAuthKey = (label: string, authKey: string): PubNub => + world.client('secured', label, { authKey: world.n(authKey) }); + + const publishTo = async (client: PubNub, channel: string): Promise => + (await client.publish({ channel, message: { text: 'am-v2' } })).timetoken; + + /** + * Closes a grant again (every permission false, ttl 1), because the pam keyset is shared with other test jobs. Category + * grants cannot use ttl 1 (the SDK refuses one-minute category grants), so they close with ttl 2. + */ + const closeGrant = (params: Record): Promise => + world.server.grant({ read: false, write: false, ttl: 1, ...params } as never); + + // Full r/w/m/d grant to one auth key on one channel. The effect check publishes with a client that carries that + // auth key, which proves the grant is live. Console logging stays because the snippet's output is shown in docs. + step('grantBasicUsage', { ...secured, proves: 'grant returns the permissions asked for on one auth key and channel, and a client carrying that auth key can publish there.' }, async () => { + const pubnub = world.server; + // snippet.grantBasicUsage + const result = await pubnub.grant({ + authKeys: ['my_authkey'], + channels: ['my_channel'], + ttl: 12313, // 0 for infinite + read: true, // false to disallow + write: true, // false to disallow + manage: true, // false to disallow + delete: true, // false to disallow + }); + // snippet.end + try { + const response = loose(result); + console.log(JSON.stringify(response)); + const channel = world.n('my_channel'); + const auth = world.n('my_authkey'); + // The SDK resolves with the payload itself, not with the status envelope the docs show. + // The docs Response block shows exactly these keys and the ttl the request sent. + assert.deepEqual(Object.keys(response).sort(), ['auths', 'channel', 'level', 'subscribe_key', 'ttl']); + assert.equal(response.ttl, 12313); + assert.equal(response.channel, channel); + assert.equal(response.level, 'user'); + const entry = response.auths?.[auth]; + assert.deepEqual([entry?.r, entry?.w, entry?.m, entry?.d], [1, 1, 1, 1], 'all four permissions are granted'); + // Effect: a client with that auth key can publish to the channel. + world.check.timetoken(await publishTo(withAuthKey('am-v2-user', 'my_authkey'), channel)); + } finally { + await closeGrant({ authKeys: [world.n('my_authkey')], channels: [world.n('my_channel')], manage: false, delete: false }); + } + }); + + // Downgrade of the same pair to read only. `needs` the first grant so the response describes a change. + // No effect check, see the comment at the end of the body. The long timeout is headroom for slow grant propagation. + step('grantChannelOnAuthKey', { ...secured, needs: ['grantBasicUsage'], timeout: 60_000, proves: 'A second grant on the same auth key and channel reports the downgrade to read only in its response.' }, async () => { + const pubnub = world.server; + // snippet.grantChannelOnAuthKey + const result = await pubnub.grant({ + authKeys: ['my_authkey'], + channels: ['my_channel'], + ttl: 5, + read: true, // false to disallow + write: false, // false to disallow + }); + // snippet.end + try { + const entry = loose(result).auths?.[world.n('my_authkey')]; + assert.deepEqual([entry?.r, entry?.w], [1, 0]); + } finally { + await closeGrant({ authKeys: [world.n('my_authkey')], channels: [world.n('my_channel')] }); + } + // No client-side effect check on purpose. Access Manager caches an earlier allow, so a publish after the + // downgrade kept succeeding for more than 60 s in live runs, and `audit()` returned no entries for a fresh + // grant (live finding). + }); + + // Grants the `-pnpres` companion channel. The snippet literal `my_channel-pnpres` is a registered placeholder + // (not a suffixed name, because the suffix has to sit before `-pnpres`), so the step sets its live value + // before the snippet runs. + step('grantPresenceChannelOnAuthKey', { ...secured, proves: 'Granting the -pnpres companion channel returns the permissions for that presence channel.' }, async () => { + const pubnub = world.server; + world.setPh('myChannelPnpres', `${world.n('my_channel')}-pnpres`); + // snippet.grantPresenceChannelOnAuthKey + const result = await pubnub.grant({ + authKeys: ['my_authkey'], + channels: ['my_channel-pnpres'], + ttl: 5, + read: true, // false to disallow + write: true, // false to disallow + }); + // snippet.end + try { + const response = loose(result); + assert.equal(response.channel, `${world.n('my_channel')}-pnpres`); + const entry = response.auths?.[world.n('my_authkey')]; + assert.deepEqual([entry?.r, entry?.w], [1, 1]); + } finally { + await closeGrant({ authKeys: [world.n('my_authkey')], channels: [`${world.n('my_channel')}-pnpres`] }); + } + }); + + // User level grant (`get`, `update`, `delete` on user metadata). Response only, no effect check. + step('grantUuidOnAuthKey', { ...secured, proves: 'A user level grant returns get, update and delete permissions for the uuid.' }, async () => { + const pubnub = world.server; + // snippet.grantUuidOnAuthKey + const result = await pubnub.grant({ + authKeys: ['key1'], + uuids: ['uuid1'], + ttl: 1440, // 0 for infinite + get: true, // false to disallow + update: true, // false to disallow + delete: true, // false to disallow + }); + // snippet.end + try { + const response = loose(result); + console.log(JSON.stringify(response)); + // The docs describe a uuid grant as `uuids: { : { auths } }` next to `level`, `ttl` and `subscribe_key`. + assert.deepEqual(Object.keys(response).sort(), ['level', 'subscribe_key', 'ttl', 'uuids']); + assert.equal(response.level, 'uuid'); + const entry = response.uuids?.[world.n('uuid1')]?.auths?.[world.n('key1')]; + assert.deepEqual([entry?.g, entry?.u, entry?.d], [1, 1, 1]); + } finally { + await world.server.grant({ authKeys: [world.n('key1')], uuids: [world.n('uuid1')], get: false, update: false, delete: false, ttl: 1 }); + } + }); + + // Category grant (all channel metadata and all uuid metadata). The id is the one the live page embeds today. + step('grantWithCategories', { ...secured, proves: 'A category grant returns the permissions for all channel metadata and all uuid metadata.' }, async () => { + const pubnub = world.server; + // snippet.grantWithCategories + // Grants the auth key permission to list all channel metadata and all uuid metadata. + // This legacy `grant` method requires the secret key to be set on the client. + const response = await pubnub.grant({ + authKeys: ['my-auth-key'], + categories: ['channels', 'uuids'], + get: true, + ttl: 15, + }); + console.log('Grant response:', response); + // snippet.end + try { + const result = loose(response); + assert.equal(result.level, 'category'); + const auth = world.n('my-auth-key'); + assert.equal(result.categories?.channels?.auths?.[auth]?.g, 1); + assert.equal(result.categories?.uuids?.auths?.[auth]?.g, 1); + } finally { + await world.server.grant({ authKeys: [world.n('my-auth-key')], categories: ['channels', 'uuids'], get: false, ttl: 2 }); + } + }); + + // Channel level grant without auth keys, so any client may use the channel. Effect check: a client with no auth + // key publishes. The grant sits on a run-suffixed channel and is closed again at the end of the step, because the same + // channel name is used later in the run by `publishHandleErrors` (publish.ts, P4), which needs a client without a token + // to be refused. That step also waits for the revoke to propagate (up to about 60 s). + step('grantChannelAllAuthKeys', { ...secured, proves: 'A channel level grant without auth keys opens the channel to a client with no auth key, and the grant is closed again at the end.' }, async () => { + const pubnub = world.server; + // snippet.grantChannelAllAuthKeys + const result = await pubnub.grant({ + channels: ['my_channel'], + read: true, // false to disallow + write: true, // false to disallow + }); + // snippet.end + // From here on the channel is open to everybody, so everything below runs inside try and the finally closes it again. + try { + const response = loose(result); + assert.equal(response.level, 'channel'); + const entry = response.channels?.[world.n('my_channel')]; + assert.deepEqual([entry?.r, entry?.w], [1, 1]); + // Effect: a client without any auth key can use the channel now. + world.check.timetoken(await publishTo(world.client('secured', 'am-denied'), world.n('my_channel'))); + } finally { + // Closes the channel for everybody again (read and write off). Runs also when an assertion above fails. + await pubnub.grant({ channels: [world.n('my_channel')], read: false, write: false, ttl: 1 }); + } + }); + + // Categories: documented validation errors, raised locally before anything is sent. + check('accessManagerV2CategoryErrors', { ...secured, proves: 'The documented category grant mistakes are refused locally with validation errors before anything is sent.' }, async () => { + const server = world.server; + await rejectsValidation( + server.grant({ categories: ['channels'], get: true, ttl: 15 }), + /authKeys are required for grant request on categories/, + ); + await rejectsValidation( + server.grant({ authKeys: ['k'], categories: ['groups' as never], get: true, ttl: 15 }), + /Invalid category: only channels and uuids are supported/, + ); + await rejectsValidation( + server.grant({ authKeys: ['k'], categories: ['channels'], get: true, read: true, ttl: 15 }), + /Category permissions must be exactly the get permission/, + ); + await rejectsValidation( + server.grant({ authKeys: ['k'], categories: ['channels'], get: true, ttl: 1 }), + /One-minute category grants are not supported/, + ); + }); + + // The application level sample opens every channel for every client, so it is the last thing this module does + // and the revoke runs even when an assertion fails. The probe is a stranger with no token and no auth key that + // subscribes: it connects while the application level read is on, otherwise the SDK reports a connection error + // or access denied. + step('grantAllChannelsAllAuthKeys', { ...secured, phase: 'P9', timeout: 120_000, proves: 'The application level grant opens the keyset to a stranger, and the revoke closes it again and the keyset stops serving the stranger.' }, async () => { + const pubnub = world.server; + let probes = 0; + const subscribeResult = async (): Promise => { + probes += 1; + const stranger = world.client('secured', `am-denied-${probes}`); + const result = expectStatus(stranger, ['PNConnectedCategory', 'PNAccessDeniedCategory', 'PNConnectionErrorCategory'], { timeout: 10_000 }); + const subscription = stranger.channel(world.n('am_other')).subscription(); + subscription.subscribe(); + try { + return await result; + } finally { + subscription.unsubscribe(); + stranger.destroy(); + } + }; + try { + // snippet.grantAllChannelsAllAuthKeys + const result = await pubnub.grant({ + read: true, + }); + // snippet.end + const response = loose(result); + assert.equal(response.level, 'subkey'); + assert.equal(response.r, 1); + assert.equal(response.ttl, 1440, 'the default ttl is 1440 minutes'); + await eventually(async () => assert.equal(await subscribeResult(), 'PNConnectedCategory'), { + timeout: 30_000, + interval: 500, + label: 'a stranger can read now', + }); + } finally { + // Three attempts and a loud warning. The Mocha root hooks, the janitor and the signal handlers revoke again. + await revokeApplicationGrant(pubnub); + } + // The revoke took, so the keyset is not left open for other modules. + await eventually(async () => assert.notEqual(await subscribeResult(), 'PNConnectedCategory'), { + timeout: 45_000, + interval: 500, + label: 'application level grant is revoked', + }); + }); +}); diff --git a/docs-snippets/api-reference/access-manager.ts b/docs-snippets/api-reference/access-manager.ts new file mode 100644 index 000000000..66fe99ea4 --- /dev/null +++ b/docs-snippets/api-reference/access-manager.ts @@ -0,0 +1,455 @@ +import assert from '../harness/assert.js'; +import type PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { eventually, connected } from '../harness/wait.js'; +import { rejectsValidation, rejectsWithCategory } from '../harness/assertions.js'; + +/** Parsed tokens list every permission flag. The docs show the granted ones, so compare only those. */ +export const granted = (permissions: object | undefined): Record => + Object.fromEntries(Object.entries(permissions ?? {}).filter(([, value]) => value === true)); + +/** + * docs/sdks/javascript/api-reference/access-manager.md (Access Manager v3 tokens) + * + * Secured keyset only. The docs call `grantToken` as a server that holds the secret key, so every snippet runs + * as `world.server`. Property keys inside `resources` and `patterns` (`'channel-a'`, `'user-bob'`) are never + * rewritten by the staging step, only values are. The authorized user ids are registered names, so each run + * grants to its own user ids and never touches another module's tokens. + * + * Alice and Bob: the check `accessManagerTokenFixture` grants them real tokens for the private room + * `am_room`, wires Bob through `world.setToken('bob', ...)` and leaves Alice's token to `setTokenBasicUsage`, + * which is the docs sample for exactly that call. + * + * Phase P1. Sneaker Drop role: the staff server (secret key) issues Access Manager (v3) tokens to the shop's + * clients. Alice (read and write) and Bob (read only) share the private room `am_room`. + * + * Proves: every `grantToken` sample returns a token the SDK can parse, and the parsed token holds the scopes the + * snippet asked for. Real enforcement is proven for the room (Alice publishes, Bob and a stranger are denied) + * and for `revokeToken` (a revoked token ends up rejected). + * Does not prove: that the DataSync scopes (`dataSync`, `dataSyncProjections`) are enforced. Those two samples + * only check that the token is created and parses, enforcement belongs to the DataSync page. Also not proven: + * the ttl expiring, which would take 15 minutes. + */ +export default page('api-reference/access-manager', 'P1', (world) => { + const secured = { keyset: 'pam' } as const; + let aliceToken = ''; + let bobToken = ''; + + /** Publishes to a channel and resolves with the timetoken. */ + const publishTo = async (client: PubNub, channel: string): Promise => { + const response = await client.publish({ channel, message: { text: 'am-check' } }); + return response.timetoken; + }; + + // Harness fixture, no snippet. Grants Alice read and write and Bob read only on `am_room`, installs Bob's token + // on his client and keeps both tokens for the steps below (`needs` this check). The assertions prove the + // fixture works, so later failures point at the snippet and not at the setup: Bob is denied on publish but + // can subscribe, and `connected` is registered before `subscribe()` because statuses are not replayed. + check('accessManagerTokenFixture', { keyset: 'pam', timeout: 60_000, proves: 'Fixture: Alice gets read and write and Bob read only on the room, and Bob is denied on publish but can subscribe.' }, async () => { + const server = world.server; + const room = world.n('am_room'); + aliceToken = await server.grantToken({ + ttl: 15, + authorizedUserId: world.alice.getUserId(), + resources: { channels: { [room]: { read: true, write: true } } }, + }); + bobToken = await server.grantToken({ + ttl: 15, + authorizedUserId: world.bob.getUserId(), + resources: { channels: { [room]: { read: true } } }, + }); + world.setToken('bob', bobToken); + assert.equal(world.bob.getToken(), bobToken); + // Bob holds a read-only token: he can subscribe to the room but not publish to it. + await rejectsWithCategory(world.bob.publish({ channel: room, message: 'x' }), 'PNAccessDeniedCategory'); + const subscription = world.bob.channel(room).subscription(); + const ready = connected(world.bob, { channels: [room] }); + subscription.subscribe(); + await ready; + subscription.unsubscribe(); + }); + + // Basic `grantToken` sample. The parsed token is checked field by field (user id with run suffix, channel + // permissions, ttl). The token is stored as the `grantedToken` placeholder for snippets that parse it later. + step('accessManagerBasicUsage', { ...secured, proves: 'grantToken returns a token whose parsed form carries the user id, channel permissions and ttl that were asked for.' }, async () => { + const pubnub = world.server; + // snippet.accessManagerBasicUsage + // Function to use grantToken method + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: 'my-authorized-uuid', + resources: { + channels: { + 'my-channel': { + read: true, + write: true, + }, + }, + }, + }); + console.log('Granted Token:', token); + // snippet.end + const parsed = pubnub.parseToken(token); + assert.equal(parsed?.authorized_uuid, world.n('my-authorized-uuid')); + assert.deepEqual(granted(parsed?.resources?.channels?.['my-channel']), { read: true, write: true }); + assert.equal(parsed?.ttl, 15); + world.setPh('grantedToken', token); + }); + + // The docs pass a literal token (a registered placeholder, so the staged snippet revokes the real one). The step + // first builds a user with a working token, so the revoke has an effect to observe. The long timeout and the + // polling exist because the service caches the non-revoked state for up to a minute. + step('revokeTokenBasicUsage', { ...secured, timeout: 240_000, proves: 'revokeToken makes a working token stop working, observed by a client that was allowed before and by two fresh clients with the same token.' }, async () => { + // Fixture: a user with a working token, so the effect of the revoke is visible. + const room = world.n('am_other'); + const revokee = world.client('secured', 'am-revokee'); + const token = await world.server.grantToken({ + ttl: 15, + authorizedUserId: revokee.getUserId(), + resources: { channels: { [room]: { read: true, write: true } } }, + }); + revokee.setToken(token); + world.check.timetoken(await publishTo(revokee, room)); + world.setPh('tokenToRevoke', token); + + const pubnub = world.server; + // snippet.revokeTokenBasicUsage + const response = await pubnub.revokeToken('p0AkFl043rhDdHRsple3KgQ3NwY6BDcENnctokenVzcqBDczaWdYIGOAeTyWGJI'); + // snippet.end + assert.deepEqual(response, {}, 'revokeToken resolves with an empty object'); + await eventually( + () => rejectsWithCategory(publishTo(revokee, room), 'PNAccessDeniedCategory'), + // The docs say a revoke can take up to one minute, because the non-revoked state is cached for that long. + { timeout: 100_000, interval: 2_000, label: 'revoked token is rejected' }, + ); + // Enforcement reaches the service nodes unevenly, so one refusal is not enough. Two fresh clients with the same user id + // and the revoked token (a client without any token would be refused anyway and prove nothing) must be refused too. + for (const attempt of [1, 2]) { + const fresh = world.client('secured', 'am-revokee'); + fresh.setToken(token); + await eventually( + () => rejectsWithCategory(publishTo(fresh, room), 'PNAccessDeniedCategory'), + { timeout: 60_000, interval: 2_000, label: `revoked token is rejected for fresh client ${attempt}` }, + ); + } + }); + + // `parseToken` is local. The snippet parses a placeholder, the assertions parse Alice's real token from the + // fixture and check owner and permissions. + step('parseTokenBasicUsage', { ...secured, needs: ['accessManagerTokenFixture'], proves: 'parseToken decodes a token locally, and the fixture token shows the right owner and permissions.' }, () => { + const pubnub = world.server; + world.setPh('grantedToken', aliceToken); + // snippet.parseTokenBasicUsage + pubnub.parseToken('use-token-string-generated-by-grantToken()'); + // snippet.end + const parsed = pubnub.parseToken(aliceToken); + assert.equal(parsed?.authorized_uuid, world.alice.getUserId()); + assert.deepEqual(granted(parsed?.resources?.channels?.[world.n('am_room')]), { read: true, write: true }); + }); + + // Alice starts without a token and is denied. The snippet installs the token (placeholder staged to Alice's + // real token), then the effect is checked: she publishes to her room, not to another one, and Bob stays read only. + step('setTokenBasicUsage', { ...secured, needs: ['accessManagerTokenFixture'], timeout: 60_000, proves: 'setToken lets Alice publish to her room but not to another, while Bob stays read only.' }, async () => { + const pubnub = world.alice; + const room = world.n('am_room'); + world.setPh('grantedToken', aliceToken); + // Without a token the secured keyset refuses Alice. + pubnub.setToken(undefined); + await rejectsWithCategory(publishTo(pubnub, room), 'PNAccessDeniedCategory'); + // snippet.setTokenBasicUsage + pubnub.setToken('use-token-string-generated-by-grantToken()'); + // snippet.end + assert.equal(pubnub.getToken(), aliceToken); + world.check.timetoken(await publishTo(pubnub, room)); + // The token names one room only. + await rejectsWithCategory(publishTo(pubnub, world.n('am_other')), 'PNAccessDeniedCategory'); + // Bob (read only) still cannot publish, he reads what Alice sent. + await rejectsWithCategory(publishTo(world.bob, room), 'PNAccessDeniedCategory'); + }); + + // Channels, groups and uuids in one token. Property keys are not suffixed (see header), so the parsed token is + // read with the plain keys. + step('grantTokenVariousResources', { ...secured, proves: 'A token with channels, groups and uuids keeps each resource with its permissions in the parsed form.' }, async () => { + const pubnub = world.server; + // snippet.grantTokenVariousResources + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: 'my-authorized-uuid', + resources: { + channels: { + 'channel-a': { + read: true, + }, + 'channel-b': { + read: true, + write: true, + }, + 'channel-c': { + read: true, + write: true, + }, + 'channel-d': { + read: true, + write: true, + }, + }, + groups: { + 'channel-group-b': { + read: true, + }, + }, + uuids: { + 'uuid-c': { + get: true, + }, + 'uuid-d': { + get: true, + update: true, + }, + }, + }, + }); + // snippet.end + const parsed = pubnub.parseToken(token); + assert.deepEqual(granted(parsed?.resources?.channels?.['channel-a']), { read: true }); + assert.deepEqual(granted(parsed?.resources?.channels?.['channel-d']), { read: true, write: true }); + assert.deepEqual(granted(parsed?.resources?.groups?.['channel-group-b']), { read: true }); + assert.deepEqual(granted(parsed?.resources?.uuids?.['uuid-d']), { get: true, update: true }); + }); + + // Pattern-only token: the regular expression key must survive unchanged in `patterns.channels`. + step('grantTokenUsingRegEx', { ...secured, proves: 'A pattern-only token keeps the regular expression unchanged under patterns.channels.' }, async () => { + const pubnub = world.server; + // snippet.grantTokenUsingRegEx + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: 'my-authorized-uuid', + patterns: { + channels: { + '^channel-[A-Za-z0-9]$': { + read: true, + }, + }, + }, + }); + // snippet.end + const parsed = pubnub.parseToken(token); + assert.deepEqual(granted(parsed?.patterns?.channels?.['^channel-[A-Za-z0-9]$']), { read: true }); + }); + + // Resources and patterns together, both read back from the parsed token. + step('grantTokenRegExAndResources', { ...secured, proves: 'A token with resources and patterns returns both sections when parsed.' }, async () => { + const pubnub = world.server; + // snippet.grantTokenRegExAndResources + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: 'my-authorized-uuid', + resources: { + channels: { + 'channel-a': { + read: true, + }, + 'channel-b': { + read: true, + write: true, + }, + 'channel-c': { + read: true, + write: true, + }, + 'channel-d': { + read: true, + write: true, + }, + }, + groups: { + 'channel-group-b': { + read: true, + }, + }, + uuids: { + 'uuid-c': { + get: true, + }, + 'uuid-d': { + get: true, + update: true, + }, + }, + }, + patterns: { + channels: { + '^channel-[A-Za-z0-9]$': { + read: true, + }, + }, + }, + }); + // snippet.end + const parsed = pubnub.parseToken(token); + assert.deepEqual(granted(parsed?.resources?.channels?.['channel-b']), { read: true, write: true }); + assert.deepEqual(granted(parsed?.patterns?.channels?.['^channel-[A-Za-z0-9]$']), { read: true }); + }); + + // Category permissions (`get` on all channel metadata and all uuid metadata). Stores the token as the + // `grantedToken` placeholder for `parseTokenWithCategories`. + step('grantTokenWithCategories', { ...secured, proves: 'A category token carries get on all channel metadata and all uuid metadata.' }, async () => { + const pubnub = world.server; + // snippet.grantTokenWithCategories + // Grants `my-authorized-uuid` permission to list all channel metadata and all uuid metadata. + // Category permissions support only the `get` operation. + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: 'my-authorized-uuid', + categories: { + channels: { get: true }, + uuids: { get: true }, + }, + }); + console.log('Granted token:', token); + // snippet.end + const parsed = pubnub.parseToken(token); + assert.equal(parsed?.categories?.channels?.get, true); + assert.equal(parsed?.categories?.uuids?.get, true); + world.setPh('grantedToken', token); + }); + + // Parses the token from the previous step through the placeholder. `needs` it because the placeholder only has + // a value after that step passed. + step('parseTokenWithCategories', { ...secured, needs: ['grantTokenWithCategories'], proves: 'parseToken reads the category permissions of the token created by the previous step.' }, () => { + const pubnub = world.server; + // snippet.parseTokenWithCategories + // Decodes a token that was granted with category permissions. + const parsedToken = pubnub.parseToken('use-token-string-generated-by-grantToken()'); + + console.log('Can list all channel metadata:', parsedToken?.categories?.channels?.get); + console.log('Can list all uuid metadata:', parsedToken?.categories?.uuids?.get); + // snippet.end + assert.equal(parsedToken?.categories?.channels?.get, true); + assert.equal(parsedToken?.categories?.uuids?.get, true); + }); + + // DataSync scopes in a token (`dataSync.entities` plus the top-level `users` scope for DataSync users). Creation + // and parsing only. The ids are registered DataSync names, so the authorized user is Alice's suffixed id. + step('grantTokenDataSyncResources', { keyset: 'datasync', proves: 'A token can carry DataSync entity scopes and the top-level users scope, and parseToken returns them.' }, async () => { + const pubnub = world.server; + // snippet.grantTokenDataSyncResources + // Grants `user-alice`: + // - Get access to the entity `product-sneaker-42`, and to all entities matching the + // `product-.*` RegEx pattern. + // - Get access to the DataSync user `user-bob` through the top-level `users` scope, because + // DataSync users are not permissioned under `dataSync`. + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: 'user-alice', + resources: { + users: { + 'user-bob': { + get: true, + }, + }, + dataSync: { + entities: { + 'product-sneaker-42': { + get: true, + }, + }, + }, + }, + patterns: { + dataSync: { + entities: { + 'product-.*': { + get: true, + }, + }, + }, + }, + }); + console.log('Granted Token:', token); + // snippet.end + const parsed = pubnub.parseToken(token); + assert.equal(parsed?.authorized_uuid, world.alice.getUserId()); + assert.ok(parsed?.resources, 'the token carries resources'); + }); + + // Same grant plus `dataSyncProjections`. Creation and parsing only. `authorizedUserId` combines with `dataSyncProjections` + // in the SDK types (checked with tsc). The projection is assigned to entities of the `product` class only, because that + // class declares `public`. The built-in Global User class declares only `__default__`, so a `public` assignment to a + // user would make the token fail with 403 when used (docs row 24, the page now assigns no user projection). + step('grantTokenWithProjection', { keyset: 'datasync', proves: 'A token with dataSyncProjections is created and parsed, and the projection assignment for an entity of a class that declares it is carried in the token.' }, async () => { + const pubnub = world.server; + // snippet.grantTokenWithProjection + // Grants `user-alice` the same access as above and assigns the `public` projection to the entity + // `product-sneaker-42` and to all entities matching `product-.*`. The entity class must declare + // a `public` projection. With it, Alice reads and writes only the fields that the projection exposes. + const token = await pubnub.grantToken({ + ttl: 15, + authorizedUserId: 'user-alice', + resources: { + users: { + 'user-bob': { + get: true, + }, + }, + dataSync: { + entities: { + 'product-sneaker-42': { + get: true, + }, + }, + }, + }, + patterns: { + dataSync: { + entities: { + 'product-.*': { + get: true, + }, + }, + }, + }, + dataSyncProjections: { + resources: { + entities: { + 'product-sneaker-42': 'public', + }, + }, + patterns: { + entities: { + 'product-.*': 'public', + }, + }, + }, + }); + console.log('Granted Token:', token); + // snippet.end + const parsed = pubnub.parseToken(token) as { meta?: Record; pat?: Record }> } | undefined; + assert.ok(parsed, 'the token parses'); + assert.equal(parsed.meta?.['pn-projections']?.res?.['datasync:entities:product-sneaker-42'], 'public'); + assert.equal(parsed.meta?.['pn-projections']?.pat?.['datasync:entities:product-.*'], 'public'); + }); + + // Error behavior the page documents or implies. Local validation, no token is created. + check('accessManagerGrantTokenErrors', { ...secured, proves: 'The documented grantToken mistakes are refused locally with validation errors and create no token.' }, async () => { + const server = world.server; + await rejectsValidation(server.grantToken({ ttl: 15 } as never), /Missing values for either Resources or Patterns/); + await rejectsValidation( + server.grantToken({ ttl: 15, resources: { channels: {} } }), + /Missing values for either Resources or Patterns/, + ); + await rejectsValidation(server.revokeToken(''), /token can't be empty/); + await rejectsValidation(world.shopper.revokeToken('some-token'), /Missing Secret Key/); + // The open client has no secret key, so it cannot grant. + await rejectsValidation( + world.shopper.grantToken({ ttl: 15, resources: { channels: { x: { read: true } } } }), + /Missing Secret Key/, + ); + }); + + // A client that never received a token is rejected on the secured keyset. + check('accessManagerDeniedWithoutToken', { ...secured, proves: 'A client that never received a token is refused with a 403 on the secured keyset.' }, async () => { + const stranger = world.client('secured', 'am-denied'); + await rejectsWithCategory(publishTo(stranger, world.n('am_room')), 'PNAccessDeniedCategory'); + }); +}); diff --git a/docs-snippets/api-reference/channel-groups.ts b/docs-snippets/api-reference/channel-groups.ts new file mode 100644 index 000000000..daa39a924 --- /dev/null +++ b/docs-snippets/api-reference/channel-groups.ts @@ -0,0 +1,115 @@ +import assert from '../harness/assert.js'; +import { page, step } from '../harness/step.js'; +import { eventually } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/api-reference/channel-groups.md + * + * Keyset: open. Phases: add and list run in P3 (the shop's rooms get wired together), deletion runs in P9 with the + * other teardown snippets. In the Sneaker Drop story a shopper groups a family's chat rooms under one name. + * + * What it proves: each snippet changes the group and a second client (`observer`) sees the change. Channel group + * reads are eventually consistent, so every effect check polls with `eventually` instead of reading once. + * What it does not prove: subscribing to a group (that is `subscribe.ts` and `subscribe-legacy.ts`, which also use + * the `family` group and rely on the cleanups below) and Access Manager rules for group calls. + */ +export default page('api-reference/channel-groups', 'P3', (world) => { + // Channel order in a group is not guaranteed, so compare sorted copies. + const sorted = (values: string[]): string[] => [...values].sort(); + + // Proves addChannels works: the shopper adds ch1 and ch2, then the observer reads the group back. + step('addChannelsToGroupBasicUsage', { proves: 'addChannels adds two channels to a group, and a second client reads them back from the group.' }, async () => { + const pubnub = world.shopper; + // snippet.addChannelsToGroupBasicUsage + const response = await pubnub.channelGroups.addChannels({ + channels: ['ch1', 'ch2'], + channelGroup: 'myChannelGroup', + }); + console.log('addChannels to Group response:', response); + // snippet.end + // Another client confirms the effect. + await eventually(async () => { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: world.n('myChannelGroup') }); + assert.deepEqual(sorted(listed.channels), sorted([world.n('ch1'), world.n('ch2')])); + }, { label: 'group lists ch1 and ch2' }); + }); + + // Proves listChannels returns what the previous step added. Needs that step so the group is not empty. + step('listChannelsInGroupBasicUsage', { needs: ['addChannelsToGroupBasicUsage'], proves: 'listChannels returns the channels the previous step added to the group.' }, async () => { + const pubnub = world.shopper; + // snippet.listChannelsInGroupBasicUsage + // assuming an intialized PubNub instance already exists + // to get some data in response, first add some channels to the group using addChannels() method. + const response = await pubnub.channelGroups.listChannels({ + channelGroup: 'myChannelGroup', + }); + console.log('Channels in the group:', response); + response.channels.forEach((channel) => { + console.log(channel); + }); + // snippet.end + assert.deepEqual(sorted(response.channels), sorted([world.n('ch1'), world.n('ch2')])); + }); + + // Seeds `family` with son and daughter, runs the removal and checks only daughter is left. + // The group is deleted at the end on purpose: subscribe-legacy.ts also uses the name `family` (the stage rewrite maps + // both pages to one live group), so leaving daughter behind would leak into that page. + step('removeChannelsFromGroupBasicUsage', { proves: 'removeChannels removes one channel from a group and leaves only the other one.' }, async () => { + const pubnub = world.shopper; + // The docs say the channel must already be in the group to observe the removal. + await world.observer.channelGroups.addChannels({ + channelGroup: world.n('family'), + channels: [world.n('son'), world.n('daughter')], + }); + await eventually(async () => { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: world.n('family') }); + assert.deepEqual(sorted(listed.channels), sorted([world.n('son'), world.n('daughter')])); + }, { label: 'family group seeded' }); + + // snippet.removeChannelsFromGroupBasicUsage + // assuming an initialized PubNub instance already exists + // and channel which is going to be removed from the group is aredaly added to the group to observe the removal + const response = await pubnub.channelGroups.removeChannels({ + channels: ['son'], + channelGroup: 'family', + }); + console.log('removeChannels from group response:', response); + // snippet.end + await eventually(async () => { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: world.n('family') }); + assert.deepEqual(listed.channels, [world.n('daughter')]); + }, { label: 'son removed, daughter stays' }); + // The docs use the group name 'family' on the legacy subscribe page too, and the stage rewrite maps both pages to one + // live group. Leave nothing behind, so the legacy fixture starts from a clean group instead of inheriting 'daughter'. + await world.observer.channelGroups.deleteGroup({ channelGroup: world.n('family') }); + await eventually(async () => { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: world.n('family') }); + assert.deepEqual(listed.channels, []); + }, { label: 'family group cleaned up after the removal sample' }); + }); + + // Teardown sample, hence P9. It seeds `family` itself so it does not depend on what other pages left in the group. + // The assertion after the snippet checks the group is empty (a deleted group lists no channels). + step('deleteChannelGroupBasicUsage', { phase: 'P9', needs: ['removeChannelsFromGroupBasicUsage'], proves: 'deleteChannelGroup removes the group, and a deleted group lists no channels.' }, async () => { + const pubnub = world.shopper; + // The group must exist to see the deletion effect. Seed it here, so this step does not depend on what other pages left in 'family'. + await world.observer.channelGroups.addChannels({ channelGroup: world.n('family'), channels: [world.n('son')] }); + await eventually(async () => { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: world.n('family') }); + // The legacy subscribe page may have put its own member into 'family' earlier. The group only has to hold 'son'. + assert.ok(listed.channels.includes(world.n('son')), `family lists son, got ${listed.channels.join(', ')}`); + }, { label: 'family group seeded for deletion' }); + // snippet.deleteChannelGroupBasicUsage + // assuming an initialized PubNub instance already exists + // and channel group which is getting deleted already exist to see the deletion effect. + const response = await pubnub.channelGroups.deleteGroup({ + channelGroup: 'family', + }); + console.log('deleteChannelGroup response:', response); + // snippet.end + await eventually(async () => { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: world.n('family') }); + assert.deepEqual(listed.channels, []); + }, { label: 'family group is empty after deletion' }); + }); +}); diff --git a/docs-snippets/api-reference/configuration-legacy.ts b/docs-snippets/api-reference/configuration-legacy.ts new file mode 100644 index 000000000..79c5135fd --- /dev/null +++ b/docs-snippets/api-reference/configuration-legacy.ts @@ -0,0 +1,150 @@ +import assert from '../harness/assert.js'; +import PubNub from 'pubnub'; +import { check, page } from '../harness/step.js'; +import { connected, eventually } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/api-reference/configuration-legacy.md + * + * The page has no code sample: a table of the deprecated `uuid`, `logVerbosity`, `cipherKey` and `useRandomIVs` + * parameters and the deprecated `stop()` method. Each row becomes a check that proves the documented behavior and + * the documented replacement. + * + * Keyset: open. Phase: P8 (legacy pages). Sneaker Drop story: an old shop client still configured with deprecated + * parameters keeps working. Proves: each deprecated parameter does what its row says, and its replacement does the + * same. Deliberately does not prove: anything about the current configuration page (configuration.ts). There are + * no snippets here, so every step is a harness-only check. + */ +export default page('api-reference/configuration-legacy', 'P8', (world) => { + // Reads the keys from the env on purpose: these checks build clients with raw (deprecated) configuration objects. + const keys = (): { publishKey: string; subscribeKey: string } => { + const open = world.env.open; + assert.ok(open, 'open keyset'); + return { publishKey: open.publishKey, subscribeKey: open.subscribeKey }; + }; + + // The logger records what the SDK logs so the checks can assert deprecation warnings without parsing stdout. + /** A logger that records the levels it is asked to handle. */ + const recorder = (): { logger: PubNub.Logger; seen: Array<{ level: string; text: string }> } => { + const seen: Array<{ level: string; text: string }> = []; + const handle = (level: string) => (m: PubNub.LogMessage) => { + seen.push({ level, text: m.messageType === 'text' ? m.message : 'details' in m ? (m.details ?? '') : '' }); + }; + return { + seen, + logger: { trace: handle('trace'), debug: handle('debug'), info: handle('info'), warn: handle('warn'), error: handle('error') }, + }; + }; + + // Row `uuid`: still read when `userId` is missing, and both together are an error. + check('configurationLegacyUuid', { proves: 'The deprecated uuid option is still read when userId is missing, and passing both is an error.' }, () => { + // "The SDK reads uuid when userId is not set." + const client = world.track(new PubNub({ ...keys(), uuid: world.n('legacy-uuid') } as unknown as PubNub.PubNubConfiguration)); + assert.equal(client.getUserId(), world.n('legacy-uuid')); + // "Setting both throws a PubNubError." + assert.throws( + () => new PubNub({ ...keys(), userId: 'a', uuid: 'b' } as unknown as PubNub.PubNubConfiguration), + (error: unknown) => (error as Error).name === 'PubNubError' && /use only 'userId'/.test((error as Error).message), + ); + }); + + // Row `logVerbosity`. The logger is injected, so no real log output and no timing is involved. + check('configurationLegacyLogVerbosity', { proves: 'logVerbosity: true switches on debug logging and logs a deprecation warning, and logLevel Debug does it without the warning.' }, () => { + // "true sets logLevel to LogLevel.Debug", also when a different logLevel is passed, and logs a deprecation warning. + const { logger, seen } = recorder(); + const client = world.track( + new PubNub({ ...keys(), userId: world.n('legacy-verbosity'), logVerbosity: true, logLevel: PubNub.LogLevel.Error, loggers: [logger] } as PubNub.PubNubConfiguration), + ); + assert.ok(client); + assert.ok(seen.some((s) => s.level === 'warn' && /logVerbosity/.test(s.text) && /deprecated/.test(s.text)), `deprecation warning logged. Saw: ${JSON.stringify(seen)}`); + assert.ok(seen.some((s) => s.level === 'debug'), 'logVerbosity: true turns on debug logging although logLevel is Error'); + + // The replacement: logLevel Debug records debug entries without the deprecation warning. + const replacement = recorder(); + world.track(new PubNub({ ...keys(), userId: world.n('legacy-verbosity-new'), logLevel: PubNub.LogLevel.Debug, loggers: [replacement.logger] })); + assert.ok(replacement.seen.some((s) => s.level === 'debug')); + assert.ok(!replacement.seen.some((s) => /logVerbosity/.test(s.text))); + }); + + // Reads a stored message with a client that has no crypto, so the cipher text is visible as a string. History is + // eventually consistent after a publish, hence `eventually`. + // Ciphertext of one message, read by a client that has no crypto at all. + const rawMessage = async (channel: string, timetoken: string): Promise => { + const raw = world.fresh('legacy-raw'); + const history = await eventually(async () => { + const response = await raw.fetchMessages({ channels: [channel], count: 25 }); + const found = response.channels[channel]?.find((m) => String(m.timetoken) === timetoken); + assert.ok(found, 'message stored'); + return found; + }, { timeout: 30_000, label: 'encrypted message stored' }); + assert.equal(typeof history.message, 'string', 'an encrypted message arrives as a string for a client without crypto'); + return history.message as string; + }; + + // Row `cipherKey`. Needs persistence because the cipher text is read back from history. The PNED header (base64 + // `UE5FRA`) tells the AES-CBC format apart from the legacy one. + check('configurationLegacyCipherKey', { needs: ['preflightPersistence'], timeout: 60_000, proves: 'The deprecated cipherKey option encrypts messages in the AES-CBC format (PNED header), read back from history.' }, async () => { + const channel = world.n('legacy_cipher'); + const cipherKey = 'legacy-cipher-key'; + const legacyClient = world.fresh('legacy-cipher', { cipherKey } as Partial); + const published = await legacyClient.publish({ channel, message: { secret: 'legacy' } }); + const cipherText = await rawMessage(channel, published.timetoken); + // "Defaults to legacy 128-bit encryption." The legacy format has no PNED header, the AES-CBC format has one. + assert.ok(!cipherText.startsWith('UE5FRA'), 'cipherKey without a crypto module produces the legacy format (no PNED header)'); + // "Pass the key to cryptoModule instead": the legacy crypto module reads it back, and so does the cipherKey client. + const viaModule = world.fresh('legacy-cipher-module', { cryptoModule: PubNub.CryptoModule.legacyCryptoModule({ cipherKey }) }); + const read = await viaModule.fetchMessages({ channels: [channel], count: 25 }); + const item = read.channels[channel]?.find((m) => String(m.timetoken) === published.timetoken); + assert.deepEqual(item?.message, { secret: 'legacy' }); + + // The AES-CBC module opt-in produces the PNED format. + const aes = world.fresh('legacy-aes', { cryptoModule: PubNub.CryptoModule.aesCbcCryptoModule({ cipherKey }) }); + const aesPublished = await aes.publish({ channel, message: { secret: 'aes' } }); + assert.ok((await rawMessage(channel, aesPublished.timetoken)).startsWith('UE5FRA'), 'aesCbcCryptoModule writes the PNED header'); + }); + + // Row `useRandomIVs`. Equal cipher text for equal input is only possible with a fixed IV, which is what makes the + // comparison a proof and not a guess. + check('configurationLegacyUseRandomIVs', { needs: ['preflightPersistence'], timeout: 60_000, proves: 'useRandomIVs false gives equal cipher text for equal input and true gives different cipher text.' }, async () => { + const channel = world.n('legacy_ivs'); + const cipherKey = 'legacy-cipher-key'; + const send = async (client: PubNub): Promise => rawMessage(channel, (await client.publish({ channel, message: { same: 'message' } })).timetoken); + + // Default (true): the initialization vector is random, so the same message encrypts differently each time. + const random = world.fresh('legacy-iv-random', { cipherKey } as Partial); + const [r1, r2] = [await send(random), await send(random)]; + assert.notEqual(r1, r2, 'random IVs make the cipher text differ'); + + // useRandomIVs: false: a fixed IV, so the cipher text repeats. + const fixed = world.fresh('legacy-iv-fixed', { cipherKey, useRandomIVs: false } as Partial); + const [f1, f2] = [await send(fixed), await send(fixed)]; + assert.equal(f1, f2, 'useRandomIVs: false repeats the cipher text'); + + // The replacement: useRandomIVs on legacyCryptoModule() behaves the same. + const replacement = world.fresh('legacy-iv-module', { cryptoModule: PubNub.CryptoModule.legacyCryptoModule({ cipherKey, useRandomIVs: false }) }); + const [m1, m2] = [await send(replacement), await send(replacement)]; + assert.equal(m1, m2); + assert.equal(m1, f1, 'the module option gives the same cipher text as the deprecated parameter'); + }); + + // Row `stop()`. The first client proves stop() unsubscribes. A second client carries the recording Warn logger and + // calls stop() last, because the deprecation warning is logged by the client that is stopped (the first one is + // already torn down). + check('configurationLegacyStop', { proves: 'stop() unsubscribes the client and logs a deprecation warning.' }, async () => { + const client = world.fresh('legacy-stop'); + const channel = world.n('ch1'); + const ready = connected(client, { channels: [channel] }); + const subscription = client.channel(channel).subscription(); + subscription.subscribe(); + await ready; + const { logger, seen } = recorder(); + const watched = world.fresh('legacy-stop-warn', { logLevel: PubNub.LogLevel.Warn, loggers: [logger] }); + + // "stop() is an alias of destroy()": it invalidates subscriptions and unsubscribes from everything. + client.stop(); + await eventually(() => assert.deepEqual(client.getSubscribedChannels(), []), { label: 'stop unsubscribes' }); + + watched.stop(); + assert.ok(seen.some((s) => s.level === 'warn' && /stop/.test(s.text) && /deprecated/.test(s.text)), `stop() logs a deprecation warning. Saw: ${JSON.stringify(seen)}`); + }); +}); diff --git a/docs-snippets/api-reference/configuration.ts b/docs-snippets/api-reference/configuration.ts new file mode 100644 index 000000000..3f9fe25da --- /dev/null +++ b/docs-snippets/api-reference/configuration.ts @@ -0,0 +1,248 @@ +import assert from '../harness/assert.js'; +import PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { watch } from '../harness/wait.js'; +import { eventually } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/api-reference/configuration.md + * + * Init snippets keep the placeholder keys the docs show. A hidden spread after the visible keys swaps in the + * live keyset (design 4.3, rule 4). Everything else in the snippets runs exactly as the docs show it. + * + * Keyset: open, except `configurationServerOpertaion` (secured, needs the secret key). Phase: P0, because every later + * page depends on construction working. Sneaker Drop story: the app boots its clients (shopper, read-only kiosk, + * staff server) before anything else happens. Proves: each constructor shape and the client getters and setters + * behave as the page says, the filter expression is applied by the service, and the constructor error table is + * accurate. Deliberately does not prove: subscribe or publish behavior beyond one `time()` call per client (the + * keys are real), or Access Manager enforcement (authKey is only stored here). + */ +export default page('api-reference/configuration', 'P0', (world) => { + // The kitchen-sink constructor. The hidden spread swaps in the live keys only (`openKeys`), so the snippet's own + // `logLevel: Debug` stays in effect. After the snippet: the user id carries the run suffix (registered name), the auth + // key is stored, the crypto module works, `time()` proves the keys are real, and the Debug logger wrote output. + // Debug output goes to console.debug and includes the configuration (keys), so it is captured, not printed. + step('configurationBasicUsage', { proves: 'The full constructor sample yields a working client with the run user id, auth key, crypto module and Debug logging, and time() succeeds.' }, async () => { + const debugLog = world.captureConsole('debug'); + // snippet.configurationBasicUsage + // Initialize PubNub with your keys + const pubnubConfig = new PubNub({ + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + publishKey: 'YOUR_PUBLISH_KEY', + userId: 'YOUR_USER_ID', + cryptoModule: PubNub.CryptoModule?.aesCbcCryptoModule({ cipherKey: 'YOUR_CIPHER_KEY' }), + authKey: 'accessMangerToken', + logLevel: PubNub.LogLevel.Debug, + ssl: true, + presenceTimeout: 130, + // snippet.hide + ...world.live.openKeys, + // snippet.show + }); + + // snippet.end + world.track(pubnubConfig); + try { + assert.equal(pubnubConfig.getUserId(), world.n('YOUR_USER_ID')); + assert.equal(pubnubConfig.getAuthKey(), 'accessMangerToken'); + // cryptoModule is active: encrypt and decrypt round trip. encrypt() with a crypto module takes a string and decrypt() JSON-parses the plain text, so pass stringified JSON. + assert.deepEqual(pubnubConfig.decrypt(pubnubConfig.encrypt(JSON.stringify({ greeting: 'hello' }))), { greeting: 'hello' }); + world.check.serverTime((await pubnubConfig.time()).timetoken); + } finally { + debugLog.restore(); + } + // logLevel: Debug from the snippet is in effect (the hidden spread no longer overrides it). + assert.ok(debugLog.lines.length > 0, 'the Debug logger wrote no output, so logLevel: Debug did not take effect'); + }); + + // The id keeps the docs' spelling ("Opertaion") because the docs embed it by that name. Secured keyset: the only + // snippet that shows a secretKey. It skips as missing-env:secured when that keyset is not configured. + step('configurationServerOpertaion', { keyset: 'pam', proves: 'The server configuration sample builds a client with the secret key and the run user id, and time() succeeds.' }, async () => { + // snippet.configurationServerOpertaion + const pubnubServer = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + userId: 'myUniqueUserId', + secretKey: 'secretKey', + heartbeatInterval: 0, + // snippet.hide + ...world.live.secured, + // snippet.show + }); + // snippet.end + world.track(pubnubServer); + assert.equal(pubnubServer.getUserId(), world.n('myUniqueUserId')); + world.check.serverTime((await pubnubServer.time()).timetoken); + }); + + // The id keeps the docs' spelling ("Real"). A subscribe-only client has no publishKey, so the SDK refuses to + // publish locally. The `time()` call shows the subscribe key itself is valid. + step('configurationRealOnlyClient', { proves: 'A client with only a subscribe key works for time() and refuses to publish locally.' }, async () => { + // snippet.configurationRealOnlyClient + // Initialize for Read Only Client + + const pubnubReadOnly = new PubNub({ + subscribeKey: 'mySubscribeKey', + userId: 'myUniqueUserId', + // snippet.hide + ...world.live.openSubscribeOnly, + // snippet.show + }); + // snippet.end + world.track(pubnubReadOnly); + // Read-only means no publishKey: publishing is refused before any request is sent. + await assert.rejects(pubnubReadOnly.publish({ channel: world.n('my_channel'), message: { text: 'x' } }), (error: unknown) => { + assert.match(JSON.stringify((error as { status?: unknown }).status), /publishKey/); + return true; + }); + world.check.serverTime((await pubnubReadOnly.time()).timetoken); + }); + + // Two clients with different crypto modules, never connected to a channel. The cross decrypt asserts the comments in + // the snippet ("decrypts data encrypted with the legacy and the 256 bit ciphers"). Pure local work. + step('configurationCryptoModule', { proves: 'Two clients with different crypto modules decrypt each other\'s data as the sample comments claim.' }, async () => { + // snippet.configurationCryptoModule + // encrypts using 256-bit AES-CBC cipher (recommended) + // decrypts data encrypted with the legacy and the 256 bit AES-CBC ciphers + const pubnub = new PubNub({ + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', + cryptoModule: PubNub.CryptoModule.aesCbcCryptoModule({ cipherKey: 'pubnubenigma' }), + // snippet.hide + ...world.live.openSubscribeOnly, + // snippet.show + }); + + // encrypts with 128-bit cipher key entropy (legacy) + // decrypts data encrypted with the legacy and the 256-bit AES-CBC ciphers + const pubnubLegacy = new PubNub({ + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', + cryptoModule: PubNub.CryptoModule.legacyCryptoModule({ cipherKey: 'pubnubenigma' }), + // snippet.hide + ...world.live.openSubscribeOnly, + // snippet.show + }); + // snippet.end + world.track(pubnub); + world.track(pubnubLegacy); + // Both modules decrypt what the other one encrypted, as the comments in the snippet say. + const payload = JSON.stringify({ note: 'same input' }); + assert.deepEqual(pubnub.decrypt(pubnubLegacy.encrypt(payload)), { note: 'same input' }); + assert.deepEqual(pubnubLegacy.decrypt(pubnub.encrypt(payload)), { note: 'same input' }); + // The two modules produce different ciphertext (random IVs, different headers), so the choice is applied. + assert.notEqual(pubnub.encrypt(payload), pubnubLegacy.encrypt(payload)); + }); + + // Uses a fresh client so renaming its user id cannot disturb the shared shopper. The `time()` call proves the client + // still works after the change. + step('setUserIdBasicUsage', { proves: 'setUserId renames the user and the client keeps working.' }, async () => { + const pubnub = world.fresh('setuserid'); + // snippet.setUserIdBasicUsage + pubnub.setUserId('myUniqueUserId'); + // snippet.end + assert.equal(pubnub.getUserId(), world.n('myUniqueUserId')); + world.check.serverTime((await pubnub.time()).timetoken); + }); + + // Fresh clients get the run suffix from world.client, so the assertion also guards that names are isolated per run. + step('getUserIdBasicUsage', { proves: 'getUserId returns the user id given at construction, including the run suffix.' }, () => { + const pubnub = world.fresh('getuserid'); + // snippet.getUserIdBasicUsage + const currentUserId = pubnub.getUserId(); + // snippet.end + assert.equal(currentUserId, pubnub.userId); + assert.ok(currentUserId.endsWith(`-r${world.runId}`), 'user id carries the run suffix'); + }); + + // Local state only: the auth key matters when Access Manager is on, which is covered by the access manager modules. + step('setAuthKeyBasicUsage', { proves: 'setAuthKey stores the auth key on the client.' }, () => { + const pubnub = world.fresh('setauthkey'); + // snippet.setAuthKeyBasicUsage + pubnub.setAuthKey('my_authkey'); + // snippet.end + // 'my_authkey' is a registered name (the Access Manager pages use it), so the staged snippet carries the suffix. + assert.equal(pubnub.getAuthKey(), world.n('my_authkey')); + }); + + // The setter runs outside the markers so this snippet shows the getter alone. 'my_authkey' is not suffixed here + // because the setter call sits outside a snippet region, where the stage rewrite does not apply. + step('getAuthKeyBasicUsage', { proves: 'getAuthKey returns the auth key that was set.' }, () => { + const pubnub = world.fresh('getauthkey'); + pubnub.setAuthKey('my_authkey'); + // snippet.getAuthKeyBasicUsage + const currentAuthKey = pubnub.getAuthKey(); + // snippet.end + assert.equal(currentAuthKey, 'my_authkey'); + }); + + // Local check of the stored expression. The wire effect is proven by the check `filterExpressionDropsMessage` below. + // The older docs literal `such=wow` is not valid filter syntax: a live probe (2026-11-03) showed the server delivers + // no message at all for it, while `such == 'wow'` delivers only the matching one. + step('setFilterExpressionBasicUsage', { proves: 'setFilterExpression stores the filter expression on the client.' }, () => { + const pubnub = world.fresh('setfilter'); + // snippet.setFilterExpressionBasicUsage + pubnub.setFilterExpression("such == 'wow'"); + // snippet.end + assert.equal(pubnub.getFilterExpression(), "such == 'wow'"); + }); + + // The setter is outside the snippet for the same reason as getAuthKeyBasicUsage. + step('getFilterExpressionBasicUsage', { proves: 'getFilterExpression returns the expression that was set.' }, () => { + const pubnub = world.fresh('getfilter'); + pubnub.setFilterExpression("such == 'wow'"); + // The snippet discards the return value, so a spy records what the call returned. + const spy = world.spy(pubnub, 'getFilterExpression'); + // snippet.getFilterExpressionBasicUsage + pubnub.getFilterExpression(); + // snippet.end + assert.deepEqual(spy.results, ["such == 'wow'"]); + }); + + // Runs in P9, last, so tearing down a client cannot affect other steps. The pre-condition `watch` makes the destroy + // observable. `eventually` waits because the unsubscribe state clears asynchronously. + step('destroyBasicUsage', { phase: 'P9', proves: 'destroy() stops a subscribed client and clears its subscriptions.' }, async () => { + const pubnub = world.fresh('destroy'); + const channel = world.n('my_channel'); + await watch(pubnub, [channel]); + assert.ok(pubnub.getSubscribedChannels().includes(channel), 'subscribed before destroy'); + // snippet.destroyBasicUsage + pubnub.destroy(); + // snippet.end + await eventually(() => assert.deepEqual(pubnub.getSubscribedChannels(), []), { label: 'destroy unsubscribes' }); + }); + + // Constructor error table of the Initialization errors section. No snippet, so check(). + check('configurationInitializationErrors', { proves: 'Each documented constructor error is thrown for the matching bad configuration.' }, () => { + const base = { subscribeKey: world.live.openSubscribeOnly.subscribeKey as string }; + assert.throws(() => new PubNub({ ...base } as unknown as PubNub.PubNubConfiguration), /'userId' not set/); + assert.throws(() => new PubNub({ ...base, userId: ' ' }), /'userId' is empty/); + assert.throws( + () => new PubNub({ ...base, userId: 'a', uuid: 'b' } as unknown as PubNub.PubNubConfiguration), + /use only 'userId'/, + ); + // The docs table says an empty string gives "'userId' is empty". The SDK checks falsiness first, so '' is "not set". + // The docs row needs a fix (live finding). The assertion follows the SDK source. + assert.throws(() => new PubNub({ ...base, userId: '' }), /'userId' not set/); + }); + + // Plain pub/sub, so no preflight dependency. The two publishes are ordered, so once + // message 2 arrives the filtered one (message 1) would already have been delivered if the server let it through. + // The filter expression from the configuration page, proven on the wire: the server drops what does not match. + check('filterExpressionDropsMessage', { phase: 'P4', timeout: 45_000, proves: 'A subscribe filter expression makes the server drop messages that do not match, observed on the wire.' }, async () => { + const channel = world.n('filter_channel'); + const subscriber = world.fresh('filtered'); + subscriber.setFilterExpression("such == 'wow'"); + const listening = await watch(subscriber, [channel]); + await world.friend.publish({ channel, message: { n: 1 }, meta: { such: 'nope' } }); + await world.friend.publish({ channel, message: { n: 2 }, meta: { such: 'wow' } }); + const delivered = await listening.next('message', (m) => (m.message as { n?: number }).n === 2); + assert.equal((delivered.userMetadata as { such?: string }).such, 'wow'); + assert.equal( + listening.events.filter((e) => e.kind === 'message').length, + 1, + 'the message with meta such=nope must be dropped by the filter', + ); + listening.stop(); + }); +}); diff --git a/docs-snippets/api-reference/datasync.ts b/docs-snippets/api-reference/datasync.ts new file mode 100644 index 000000000..5631f7b3d --- /dev/null +++ b/docs-snippets/api-reference/datasync.ts @@ -0,0 +1,1443 @@ +import assert from '../harness/assert.js'; +import type PubNub from 'pubnub'; +import { page, step, check, skipStep } from '../harness/step.js'; +import { connected, eventually } from '../harness/wait.js'; +import { filterFragments, patchFragments, sortFragments } from '../fixtures/datasync-fragments.js'; + +/** + * docs/sdks/javascript/api-reference/datasync.md, plus the four DataSync samples of the subscribe page + * (addDataSyncListener, subscribeToDataSyncEntity, observeDataSyncEntitiesByPattern, combineDataSyncSubscriptions). + * Those four live here because they need the same fixtures. They are still reported under this page. + * + * Keyset: `secured` (Access Manager and DataSync). Every snippet runs as `world.server`, the client that holds the + * secret key and signs each request, so no token plumbing is needed. Token-holder behaviour (Alice, Bob) belongs + * to the Access Manager module. + * + * Classes: users, channels and memberships use the Global classes PubNub seeds. Entities need the `product` class + * and relationships need `ProductOwner`. Both come from provision/datasync-classes.json. While a class is missing, + * its steps skip as `blocked-by:datasyncClassProduct` and `blocked-by:datasyncClassProductOwner`. + * + * Events: DataSync publishes events only for class versions with an event rule, and that rule is switched on in + * the Admin Portal (no Admin API endpoint). The `datasyncEvents` check probes which object kinds publish. Event + * steps skip with `needs-feature:datasync-events` (an allowed skip, see harness/policy.ts) for the kinds that do not. + * + * Two snippet ids differ from the docs: `createChannelBasicUsage` (also used by the subscribe page) and + * `getMembershipBasicUsage` (also used by the App Context page) became `createDataSyncChannelBasicUsage` and + * `getDataSyncMembershipBasicUsage`, because snippet ids are unique across the suite. The datasync page embeds the + * old names from basic-usage/data-sync.ts and needs the new ones when it switches to this app. + * + * Doc inconsistencies the steps work around, all found by live runs: + * - The membership and relationship create samples set no `id`, but the get, set, patch and remove samples use + * fixed ids. The create steps remove the generated object and recreate it with the id the later samples use. + * - The two membership filter samples used to filter on `role`, which the Global Membership class does not declare + * (the service answers 400 DS-1001). They now filter on the built-in `status` field. The check + * `datasyncMembershipFilterRejected` records that payload fields stay unfilterable. + * + * Phase P2 (App Context era data model, after Access Manager in P1). Sneaker Drop role: DataSync is the shop's + * live catalogue. Alice (`user-alice`, a shopper) joins the `channel-summer-sale` drop, Bob (`seller-bob`) owns the + * `product-sneaker-42` entity through a `ProductOwner` relationship. The steps run in the page's order: users, + * channels, memberships, entities, events, relationships, then removal. + * + * Proves: each docs call against the live service returns what the page says (ids with the run suffix, payloads, + * eTags, sorting, filters, cursor paging) and that removed objects answer 404. + * Does not prove: token-holder access (Access Manager module), projections that filter events, or event delivery + * when the keyset's event rules are off. Those steps still prove the subscription connects, then skip with + * `needs-feature:datasync-events`. Missing classes skip as `blocked-by:*`. + * The signature and response blocks of the page are not run here. + */ +export default page('api-reference/datasync', 'P2', (world) => { + const secured = { keyset: 'datasync' as const }; + const api = (): PubNub['dataSync'] => world.server.dataSync; + + // ---- helpers ----------------------------------------------------------------------------------------------- + + // The SDK error carries the service answer in `status`. Assertions match on this one string so a changed status + // code or DS- error code shows up in the failure message. + const errorText = (error: unknown): string => { + const status = (error as { status?: { errorData?: unknown; statusCode?: number } } | undefined)?.status; + return `${status?.statusCode ?? ''} ${JSON.stringify(status?.errorData ?? {})}`; + }; + const missingClass = (error: unknown): boolean => /404/.test(errorText(error)) && /DS-0100/.test(errorText(error)); + + // DS-0100 with HTTP 404 means the class does not exist on the keyset. These probes are cached promises, so the + // whole page asks the service once. Any other error is a real failure and is re-thrown. + let productClass: Promise | undefined; + let ownerClass: Promise | undefined; + const hasProductClass = (): Promise => + (productClass ??= api() + .getEntities({ class: 'product', limit: 1 }) + .then(() => true, (error) => (missingClass(error) ? false : Promise.reject(error)))); + const hasOwnerClass = (): Promise => + (ownerClass ??= api() + .getRelationships({ class: 'ProductOwner', limit: 1 }) + .then(() => true, (error) => (missingClass(error) ? false : Promise.reject(error)))); + + // Event probe: DataSync only publishes events for class versions whose event rule is on, and nothing in the API + // reports that. So the page writes one object of each kind while listening, once, and remembers what arrived. + type EventKind = 'user' | 'channel' | 'membership' | 'entity' | 'relationship'; + const EVENT_LABEL: Record = { + user: 'User v1', + channel: 'Channel v1', + membership: 'Membership v1', + entity: 'product v1', + relationship: 'ProductOwner v1', + }; + /** How long the probe waits for the create events of its own writes. */ + const PROBE_WINDOW_MS = 20_000; + let eventProbe: Promise> | undefined; + + /** Creates one object of each kind while listening, and reports the kinds that produced a create event. */ + async function probeEvents(): Promise> { + const client = world.server; + const found = new Set(); + const [userId, channelId, productId] = [world.n('ds-probe-user'), world.n('ds-probe-channel'), world.n('ds-probe-product')]; + const subscriptions = [ + client.dataSyncUser(userId).subscription(), + client.dataSyncChannel(channelId).subscription(), + client.dataSyncEntity(productId).subscription(), + ]; + for (const subscription of subscriptions) { + subscription.onDataSync = (event) => { + if (event.message.event === 'create') found.add(event.message.objectType as EventKind); + }; + } + const ready = connected(client, { channels: [userId, channelId, productId] }); + subscriptions.forEach((s) => s.subscribe()); + await ready; + const withProduct = await hasProductClass(); + const withOwner = withProduct && (await hasOwnerClass()); + const expected = new Set(['user', 'channel', 'membership']); + if (withProduct) expected.add('entity'); + if (withOwner) expected.add('relationship'); + const dataSync = client.dataSync; + await dataSync.createUser({ id: userId, data: { classVersion: 1, payload: { name: 'Probe' } } }); + await dataSync.createChannel({ id: channelId, data: { classVersion: 1, payload: { name: 'Probe' } } }); + await dataSync.createMembership({ userId, channelId, data: { classVersion: 1 } }); + if (withProduct) { + await dataSync.createEntity({ id: productId, class: 'product', data: { classVersion: 1, payload: { name: 'Probe', price: 1 } } }); + } + if (withOwner) { + await dataSync.createRelationship({ class: 'ProductOwner', entityAId: userId, entityBId: productId, data: { classVersion: 1 } }); + } + try { + await eventually(() => assert.ok([...expected].every((kind) => found.has(kind))), { timeout: PROBE_WINDOW_MS, interval: 500, label: 'probe events' }); + } catch { + // Whatever arrived is what is switched on. + } + for (const subscription of subscriptions) subscription.unsubscribe(); + // Deleting an entity removes its relationships, deleting a user or channel removes its memberships. + if (withProduct) await dataSync.removeEntity({ id: productId }).catch(() => undefined); + await dataSync.removeUser({ id: userId }).catch(() => undefined); + await dataSync.removeChannel({ id: channelId }).catch(() => undefined); + return found; + } + const events = (): Promise> => (eventProbe ??= probeEvents()); + + /** The kinds among `kinds` whose create event the probe did not see. Empty when every needed event rule is on. */ + async function offKinds(...kinds: EventKind[]): Promise { + const on = await events(); + return kinds.filter((kind) => !on.has(kind)); + } + + /** + * Skips the calling step when `off` is not empty. + * + * The skip is `needs-feature:datasync-events`: the keyset lacks a switch that only the Admin Portal can set (DataSync + * tab, "Select entities to sync", no Admin API endpoint). That tag is an allowed skip, it does not count against + * `DOCS_SNIPPETS_SKIP_BUDGET`, and `DOCS_SNIPPETS_REQUIRE_FEATURES=datasync-events` turns it into a failure for a CI job whose + * keyset has the rules on. The detail names the class versions whose create event did not arrive within the probe + * window, so the skip says what was observed (no event) and what to do, not a guess about why. + * + * Event steps call this only after their subscription is connected (see `eventStep` users). With the rules off the + * step has still proved that the snippet's subscription code runs and the SDK connects to the data channels + * (wildcard and projection channels included). Only the delivery of events is left unverified, and that is + * exactly what the skip reports. + */ + function skipUnlessEvents(off: EventKind[]): void { + if (off.length > 0) { + skipStep( + 'needs-feature:datasync-events', + `subscription connected, but no create event arrived within ${PROBE_WINDOW_MS / 1000} s for ${off.map((k) => EVENT_LABEL[k]).join(', ')}, ` + + `so event delivery was not verified. Switch the event rule on for each in the Admin Portal, DataSync tab, then rerun.`, + ); + } + } + + // Live ids of the fixed objects the docs samples use. Snippets show the plain literal, the staging rewrite + // suffixes it, and the assertions use these to compare with what the service returned. + const user = (): string => world.n('user-alice'); + const channel = (): string => world.n('channel-summer-sale'); + const membership = (): string => world.n('membership-alice-summer-sale'); + const product = (): string => world.n('product-sneaker-42'); + const seller = (): string => world.n('seller-bob'); + const relationship = (): string => world.n('rel-bob-owns-sneaker-42'); + + const ids = (response: { data: Array<{ id: string }> }): string[] => response.data.map((o) => o.id); + + // ---- preconditions ----------------------------------------------------------------------------------------- + + // Precondition, no snippet. Skips (not fails) when the `product` class is not provisioned, and every entity step + // then skips as `blocked-by:datasyncClassProduct`. Provisioning is a separate approved action (provision/). + check('datasyncClassProduct', { ...secured, proves: 'Precondition: the product class exists on the keyset, otherwise dependent DataSync steps skip.' }, async () => { + if (!(await hasProductClass())) { + skipStep('blocked-by:datasync-class-missing (product v1), run provision/provision-datasync.ts --apply after approval'); + } + const klass = await api().getEntities({ class: 'product', classVersion: 1, limit: 1 }); + assert.equal(klass.status, 200); + }); + + // Same for the `ProductOwner` relationship class. `needs` the product class because a relationship links entities. + check('datasyncClassProductOwner', { ...secured, needs: ['datasyncClassProduct'], proves: 'Precondition: the ProductOwner relationship class exists on the keyset.' }, async () => { + if (!(await hasOwnerClass())) { + skipStep('blocked-by:datasync-class-missing (ProductOwner v1), run provision/provision-datasync.ts --apply after approval'); + } + }); + + // Runs the event probe and logs which object kinds publish events. Never fails: event steps decide on their own + // whether to skip. The long timeout covers the probe window plus connect time. + check('datasyncEvents', { ...secured, timeout: 90_000, proves: 'Probes which DataSync object kinds publish events, so event steps can skip when event rules are off.' }, async () => { + const on = await events(); + console.log(`DataSync event rules on: ${[...on].map((k) => EVENT_LABEL[k]).join(', ') || 'none'}`); + }); + + // ---- users ------------------------------------------------------------------------------------------------- + + // Creates Alice, the root of the user fixtures. Asserts the echoed payload and that an eTag is returned (later + // steps rely on it). Every user step `needs` this one. + step('createUserBasicUsage', { ...secured, proves: 'createUser creates Alice, echoes the payload and returns an eTag.' }, async () => { + const pubnub = world.server; + // snippet.createUserBasicUsage + const response = await pubnub.dataSync.createUser({ + id: 'user-alice', + data: { + classVersion: 1, + payload: { name: 'Alice', type: 'shopper' }, + }, + }); + console.log('createUser response:', response.data); + // snippet.end + assert.ok(response.status >= 200 && response.status < 300, `status ${response.status}`); + assert.equal(response.data.id, user()); + assert.equal(response.data.entityClassVersion, 1); + assert.deepEqual(response.data.payload, { name: 'Alice', type: 'shopper' }); + assert.ok(response.data.eTag, 'response carries an eTag'); + }); + + // Reads Alice back by id. + step('getUserBasicUsage', { ...secured, needs: ['createUserBasicUsage'], proves: 'getUser reads Alice back by id.' }, async () => { + const pubnub = world.server; + // snippet.getUserBasicUsage + const response = await pubnub.dataSync.getUser({ id: 'user-alice' }); + console.log('getUser response:', response.data); + // snippet.end + assert.equal(response.data.id, user()); + assert.equal(response.data.payload?.name, 'Alice'); + }); + + // Lists users sorted by `createdAt`. The sort is verified on the returned timestamps, since the snippet only logs. + step('getUsersBasicUsage', { ...secured, needs: ['createUserBasicUsage'], proves: 'getUsers lists users sorted by createdAt, verified on the returned timestamps.' }, async () => { + const pubnub = world.server; + // snippet.getUsersBasicUsage + const response = await pubnub.dataSync.getUsers({ + limit: 20, + sort: { createdAt: 'asc' }, + }); + console.log('getUsers response:', response.data); + console.log('getUsers meta:', response.meta); + // snippet.end + assert.ok(Array.isArray(response.data)); + assert.ok(response.data.length <= 20); + assert.equal(typeof response.meta?.has_next, 'boolean'); + const created = response.data.map((u) => Date.parse(u.createdAt)); + assert.deepEqual(created, [...created].sort((a, b) => a - b), 'sorted by createdAt ascending'); + }); + + // `filter` runs on eventually consistent storage, so the step polls with the same expression until Alice is + // indexed, then runs the snippet once. + step('getUsersFilterExample', { ...secured, needs: ['createUserBasicUsage'], timeout: 60_000, proves: 'getUsers with filter finds Alice once the eventually consistent index has caught up.' }, async () => { + const pubnub = world.server; + // `filter` is eventually consistent, so wait until the service indexed the user before running the sample. + await eventually(async () => { + assert.ok(ids(await api().getUsers({ filter: 'name LIKE "*Alice*"' })).includes(user())); + }, { timeout: 45_000, label: 'filter index caught up with the new user' }); + // snippet.getUsersFilterExample + // `filter` runs over properties declared with filtering mode `full` and is evaluated against + // eventually consistent storage. + const response = await pubnub.dataSync.getUsers({ + filter: 'name LIKE "*Alice*"', + }); + console.log('getUsers (filter) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(user()), 'the filter finds Alice'); + }); + + // `filterFast` is strongly consistent, so no wait is needed right after the create. + step('getUsersFilterFastExample', { ...secured, needs: ['createUserBasicUsage'], proves: 'getUsers with filterFast finds Alice at once.' }, async () => { + const pubnub = world.server; + // snippet.getUsersFilterFastExample + // `filterFast` is evaluated against strongly consistent storage, so it matches an object you just + // wrote. + const response = await pubnub.dataSync.getUsers({ + filterFast: 'type == "shopper"', + }); + console.log('getUsers (filterFast) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(user()), 'filterFast sees the user right after the write'); + }); + + // Cursor loop from the page. Console output is captured to prove the loop ran and listed Alice. Real multi-page + // paging is covered by the check `datasyncCursorPaging`. + step('getUsersCursorPagingExample', { ...secured, needs: ['createUserBasicUsage'], proves: 'The cursor paging loop for users runs and lists Alice.' }, async () => { + const pubnub = world.server; + await world.withConsole(async (logs) => { + // snippet.getUsersCursorPagingExample + // Pass no `cursor` on the first call. Take `meta.next_cursor` from the response and pass it back + // as `cursor` on the next call. Stop when `meta.has_next` is `false`. + let cursor: string | undefined; + let hasNext = true; + let page = 0; + + while (hasNext) { + const response = await pubnub.dataSync.getUsers({ + filterFast: 'type == "shopper"', + limit: 20, + cursor, + }); + console.log(`Page ${++page}:`, response.data); + cursor = response.meta?.next_cursor ?? undefined; + hasNext = response.meta?.has_next ?? false; + } + // snippet.end + assert.ok(logs.lines.some((line) => line.startsWith('Page 1:')), 'the loop ran at least once'); + assert.ok(logs.lines.some((line) => line.includes(user())), 'a page lists Alice'); + }); + }); + + // `setUser` with `ifMatchesEtag`. The docs show an example eTag (a registered placeholder), so the step stores + // Alice's real eTag first. Afterwards it replays the old eTag to prove the guard rejects stale writes. Records a + // docs bug: the page promises 412, the service answers 403. + step('updateUserBasicUsage', { ...secured, needs: ['createUserBasicUsage'], proves: 'setUser with ifMatchesEtag replaces Alice and a stale eTag is rejected.' }, async () => { + const pubnub = world.server; + // The sample shows an example eTag. Use the real one, otherwise the guard (correctly) rejects the write. + world.setPh('userEtag', (await api().getUser({ id: user() })).data.eTag); + // snippet.updateUserBasicUsage + const response = await pubnub.dataSync.setUser({ + id: 'user-alice', + data: { + classVersion: 1, + payload: { name: 'Alice B.', type: 'shopper' }, + }, + ifMatchesEtag: 'AbQdEfGhIjKlMn', + }); + console.log('setUser response:', response.data); + // snippet.end + assert.equal(response.data.payload?.name, 'Alice B.'); + assert.notEqual(response.data.eTag, world.ph('userEtag'), 'the write produced a new eTag'); + await assert.rejects( + () => api().setUser({ id: user(), data: { classVersion: 1, payload: { name: 'stale' } }, ifMatchesEtag: world.ph('userEtag') }), + // The docs promise 412. The service answers 403 "Forbidden" from "Access Manager" (live finding). + (error: unknown) => /\b(412|403)\b/.test(errorText(error)), + 'the old eTag is now stale', + ); + }); + + // JSON Patch style `updateUser`. Needs the previous step so the starting name is known. + step('patchUserBasicUsage', { ...secured, needs: ['updateUserBasicUsage'], proves: 'updateUser applies a patch to the user name.' }, async () => { + const pubnub = world.server; + // snippet.patchUserBasicUsage + const response = await pubnub.dataSync.updateUser({ + id: 'user-alice', + replace: { '/payload/name': 'Alice B.', '/status': 'active' }, + }); + console.log('updateUser response:', response.data); + // snippet.end + assert.equal(response.data.payload?.name, 'Alice B.'); + assert.equal(response.data.status, 'active'); + }); + + // ---- channels ---------------------------------------------------------------------------------------------- + + // Creates the drop channel. Renamed from `createChannelBasicUsage` because that id is also used by another page + // (ids are unique across the suite). + step('createDataSyncChannelBasicUsage', { ...secured, proves: 'createChannel creates the drop channel and echoes the payload.' }, async () => { + const pubnub = world.server; + // snippet.createDataSyncChannelBasicUsage + const response = await pubnub.dataSync.createChannel({ + id: 'channel-summer-sale', + data: { + classVersion: 1, + payload: { name: 'Summer Sale', type: 'promotion' }, + }, + }); + console.log('createChannel response:', response.data); + // snippet.end + assert.equal(response.data.id, channel()); + assert.deepEqual(response.data.payload, { name: 'Summer Sale', type: 'promotion' }); + }); + + // Reads the channel back. + step('getChannelBasicUsage', { ...secured, needs: ['createDataSyncChannelBasicUsage'], proves: 'getChannel reads the DataSync channel back by id.' }, async () => { + const pubnub = world.server; + // snippet.getChannelBasicUsage + const response = await pubnub.dataSync.getChannel({ id: 'channel-summer-sale' }); + console.log('getChannel response:', response.data); + // snippet.end + assert.equal(response.data.id, channel()); + assert.equal(response.data.payload?.name, 'Summer Sale'); + }); + + // Lists channels, checks the page envelope (`data` array and `meta.has_next`). + step('getChannelsBasicUsage', { ...secured, needs: ['createDataSyncChannelBasicUsage'], proves: 'getChannels lists channels with the data array and meta.has_next envelope.' }, async () => { + const pubnub = world.server; + // snippet.getChannelsBasicUsage + const response = await pubnub.dataSync.getChannels({ limit: 20 }); + console.log('getChannels response:', response.data); + console.log('getChannels meta:', response.meta); + // snippet.end + assert.ok(Array.isArray(response.data)); + assert.equal(typeof response.meta?.has_next, 'boolean'); + }); + + // Polls until the eventually consistent `filter` index contains the new channel, then runs the snippet. + step('getChannelsFilterExample', { ...secured, needs: ['createDataSyncChannelBasicUsage'], timeout: 60_000, proves: 'getChannels with filter finds the new channel once the index has caught up.' }, async () => { + const pubnub = world.server; + await eventually(async () => { + assert.ok(ids(await api().getChannels({ filter: 'name LIKE "*Sale*"' })).includes(channel())); + }, { timeout: 45_000, label: 'filter index caught up with the new channel' }); + // snippet.getChannelsFilterExample + const response = await pubnub.dataSync.getChannels({ + filter: 'name LIKE "*Sale*"', + }); + console.log('getChannels (filter) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(channel())); + }); + + // Strongly consistent filter, no wait. + step('getChannelsFilterFastExample', { ...secured, needs: ['createDataSyncChannelBasicUsage'], proves: 'getChannels with filterFast finds the channel at once.' }, async () => { + const pubnub = world.server; + // snippet.getChannelsFilterFastExample + const response = await pubnub.dataSync.getChannels({ + filterFast: 'type == "promotion"', + }); + console.log('getChannels (filterFast) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(channel())); + }); + + // Cursor loop with console capture, same pattern as for users. + step('getChannelsCursorPagingExample', { ...secured, needs: ['createDataSyncChannelBasicUsage'], proves: 'The cursor paging loop for channels runs and lists the channel.' }, async () => { + const pubnub = world.server; + await world.withConsole(async (logs) => { + // snippet.getChannelsCursorPagingExample + let cursor: string | undefined; + let hasNext = true; + let page = 0; + + while (hasNext) { + const response = await pubnub.dataSync.getChannels({ + filterFast: 'type == "promotion"', + limit: 20, + cursor, + }); + console.log(`Page ${++page}:`, response.data); + cursor = response.meta?.next_cursor ?? undefined; + hasNext = response.meta?.has_next ?? false; + } + // snippet.end + assert.ok(logs.lines.some((line) => line.includes(channel())), 'a page lists the channel'); + }); + }); + + // Full replace with `setChannel`. + step('updateChannelBasicUsage', { ...secured, needs: ['createDataSyncChannelBasicUsage'], proves: 'setChannel replaces the channel payload.' }, async () => { + const pubnub = world.server; + // snippet.updateChannelBasicUsage + const response = await pubnub.dataSync.setChannel({ + id: 'channel-summer-sale', + data: { + classVersion: 1, + payload: { name: 'Summer Sale 2026', type: 'promotion' }, + }, + }); + console.log('setChannel response:', response.data); + // snippet.end + assert.equal(response.data.payload?.name, 'Summer Sale 2026'); + }); + + // Patches one field and checks the other payload fields survive. + step('patchChannelBasicUsage', { ...secured, needs: ['updateChannelBasicUsage'], proves: 'updateChannel patches one field and the other payload fields survive.' }, async () => { + const pubnub = world.server; + // snippet.patchChannelBasicUsage + const response = await pubnub.dataSync.updateChannel({ + id: 'channel-summer-sale', + replace: { '/payload/name': 'Summer Sale 2026' }, + }); + console.log('updateChannel response:', response.data); + // snippet.end + assert.equal(response.data.payload?.name, 'Summer Sale 2026'); + assert.equal(response.data.payload?.type, 'promotion', 'the patch leaves other fields alone'); + }); + + // ---- memberships ------------------------------------------------------------------------------------------- + + // Creates Alice's membership with the fixed id that the later samples read, set, patch and remove. + step('createMembershipBasicUsage', { ...secured, needs: ['createUserBasicUsage', 'createDataSyncChannelBasicUsage'], proves: 'createMembership links Alice to the channel with the fixed id that later steps use.' }, async () => { + const pubnub = world.server; + // snippet.createMembershipBasicUsage + const response = await pubnub.dataSync.createMembership({ + id: 'membership-alice-summer-sale', + userId: 'user-alice', + channelId: 'channel-summer-sale', + data: { + classVersion: 1, + payload: { role: 'viewer' }, + }, + }); + console.log('createMembership response:', response.data); + // snippet.end + assert.equal(response.data.id, membership()); + assert.equal(response.data.userId, user()); + assert.equal(response.data.channelId, channel()); + assert.equal(response.data.relationshipClass, 'Membership'); + assert.deepEqual(response.data.payload, { role: 'viewer' }); + }); + + // Reads the membership by its fixed id. Renamed from `getMembershipBasicUsage` (id clash with the App Context + // page). + step('getDataSyncMembershipBasicUsage', { ...secured, needs: ['createMembershipBasicUsage'], proves: 'getMembership reads the membership by its fixed id.' }, async () => { + const pubnub = world.server; + // snippet.getDataSyncMembershipBasicUsage + const response = await pubnub.dataSync.getMembership({ + id: 'membership-alice-summer-sale', + }); + console.log('getMembership response:', response.data); + // snippet.end + assert.equal(response.data.id, membership()); + assert.equal(response.data.payload?.role, 'viewer'); + }); + + // Lists Alice's memberships. Expects exactly the one from the fixture. + step('getMembershipsBasicUsage', { ...secured, needs: ['createMembershipBasicUsage'], proves: 'getMemberships lists exactly the membership of Alice.' }, async () => { + const pubnub = world.server; + // snippet.getMembershipsBasicUsage + const response = await pubnub.dataSync.getMemberships({ + userId: 'user-alice', + limit: 20, + }); + console.log('getMemberships response:', response.data); + console.log('getMemberships meta:', response.meta); + // snippet.end + assert.deepEqual(ids(response), [membership()]); + assert.equal(response.data[0].channelId, channel()); + }); + + // Same list from the channel side, expects Alice as the only member. + step('getMembershipsByChannelIdExample', { ...secured, needs: ['createMembershipBasicUsage'], proves: 'getMemberships by channel id lists Alice as the only member.' }, async () => { + const pubnub = world.server; + // snippet.getMembershipsByChannelIdExample + // Pass `channelId` instead of `userId` to list the members of a channel rather than a user's + // memberships. + const response = await pubnub.dataSync.getMemberships({ + channelId: 'channel-summer-sale', + limit: 20, + }); + console.log('getMemberships (channelId) response:', response.data); + // snippet.end + assert.deepEqual(response.data.map((m) => m.userId), [user()]); + }); + + // ---- membership filters -------------------------------------------------------------------------------------- + // + // The Global Membership class declares no payload properties and cannot be extended, so a membership `payload` + // is never indexed and filters cannot reach it. What a membership offers are the built-in fields: `id`, + // `createdAt`, `updatedAt`, `status`, `createdBy` and `modifiedBy`. Of these `status` is the one an application + // sets itself (a free-form lifecycle string), which makes it the natural field to filter memberships on. Both + // filter tiers accept the built-in fields without any class declaration (probed live: both answer on a fresh write). + // + // Both samples need two memberships of Alice, one that matches and one that does not. The memberships created by + // `createMembershipBasicUsage` carry no `status`. The helper below adds a second membership of Alice, on a second + // channel, with `status: 'archived'`. The two samples then have to return exactly that one and leave the + // original membership out. + + /** Creates the channel `channel-winter-sale` and Alice's archived membership on it. Returns the cleanup. */ + async function seedArchivedMembership(): Promise<{ id: string; cleanup: () => Promise }> { + const channelId = world.n('channel-winter-sale'); + const id = world.n('membership-alice-winter-sale'); + await api().createChannel({ id: channelId, data: { classVersion: 1, payload: { name: 'Winter Sale 2026' } } }); + await api().createMembership({ id, userId: user(), channelId, data: { classVersion: 1, status: 'archived' } }); + // Removing the channel also removes the memberships that point at it. + return { id, cleanup: () => api().removeChannel({ id: channelId }).then(() => undefined) }; + } + + // Seeds a second, archived membership (see the section comment) so the filter has something to exclude, waits for + // the eventually consistent index, then always cleans the seed in `finally`. + step('getMembershipsFilterExample', { ...secured, needs: ['createMembershipBasicUsage'], timeout: 60_000, proves: 'getMemberships with filter excludes a seeded archived membership once the index has caught up.' }, async () => { + const pubnub = world.server; + const seeded = await seedArchivedMembership(); + try { + // `filter` is eventually consistent, so wait until the service indexed the new membership. + await eventually(async () => { + assert.ok(ids(await api().getMemberships({ userId: user(), filter: 'status LIKE "arch*"' })).includes(seeded.id)); + }, { timeout: 45_000, label: 'filter index caught up with the new membership' }); + // snippet.getMembershipsFilterExample + // `status` is a built-in field, so it is filterable without any declaration. The Membership class + // declares no `payload` properties, which means a filter cannot reference anything inside `payload`. + // `LIKE` takes `*` as a wildcard. This call returns Alice's memberships whose status starts with `arch`. + const response = await pubnub.dataSync.getMemberships({ + userId: 'user-alice', + filter: 'status LIKE "arch*"', + }); + console.log('getMemberships (filter) response:', response.data); + // snippet.end + assert.deepEqual(ids(response), [seeded.id], 'only the archived membership matches'); + assert.ok(!ids(response).includes(membership()), 'the membership without a status is filtered out'); + const unfiltered = await api().getMemberships({ userId: user() }); + assert.ok(ids(unfiltered).length >= 2 && ids(unfiltered).includes(membership()), 'without the filter both memberships are listed'); + } finally { + await seeded.cleanup(); + } + }); + + // Same seed, strongly consistent filter, so no wait. + step('getMembershipsFilterFastExample', { ...secured, needs: ['createMembershipBasicUsage'], proves: 'getMemberships with filterFast excludes the seeded archived membership at once.' }, async () => { + const pubnub = world.server; + const seeded = await seedArchivedMembership(); + try { + // snippet.getMembershipsFilterFastExample + // `filterFast` is evaluated against strongly consistent storage, so it matches a membership you just + // wrote. It accepts the same built-in fields. This call returns Alice's archived memberships. + const response = await pubnub.dataSync.getMemberships({ + userId: 'user-alice', + filterFast: 'status == "archived"', + }); + console.log('getMemberships (filterFast) response:', response.data); + // snippet.end + assert.deepEqual(ids(response), [seeded.id], 'only the archived membership matches, right after the write'); + assert.equal(response.data[0].status, 'archived'); + assert.ok(!ids(response).includes(membership()), 'the membership without a status is filtered out'); + } finally { + await seeded.cleanup(); + } + }); + + // Documents the limit the two samples above work around: a field of the membership `payload` is not filterable. + check('datasyncMembershipFilterRejected', { ...secured, proves: 'Filtering on a field of the membership payload is refused, which is the limit the filter samples work around.' }, async () => { + for (const parameters of [{ filter: 'role LIKE "*mod*"' }, { filterFast: 'role == "viewer"' }]) { + await assert.rejects( + () => api().getMemberships({ userId: user(), ...parameters }), + (error: unknown) => /400/.test(errorText(error)) && /DS-1001/.test(errorText(error)) && /Unknown field: role/.test(errorText(error)), + `the service rejects ${JSON.stringify(parameters)}`, + ); + } + }); + + // Cursor loop over Alice's memberships with console capture. + step('getMembershipsCursorPagingExample', { ...secured, needs: ['createMembershipBasicUsage'], proves: 'The cursor paging loop for memberships runs and lists the membership.' }, async () => { + const pubnub = world.server; + await world.withConsole(async (logs) => { + // snippet.getMembershipsCursorPagingExample + let cursor: string | undefined; + let hasNext = true; + let page = 0; + + while (hasNext) { + const response = await pubnub.dataSync.getMemberships({ + userId: 'user-alice', + limit: 20, + cursor, + }); + console.log(`Page ${++page}:`, response.data); + cursor = response.meta?.next_cursor ?? undefined; + hasNext = response.meta?.has_next ?? false; + } + // snippet.end + assert.ok(logs.lines.some((line) => line.includes(membership())), 'a page lists the membership'); + }); + }); + + // Full replace of the membership payload. + step('updateMembershipBasicUsage', { ...secured, needs: ['createMembershipBasicUsage'], proves: 'setMembership replaces the membership payload.' }, async () => { + const pubnub = world.server; + // snippet.updateMembershipBasicUsage + const response = await pubnub.dataSync.setMembership({ + id: 'membership-alice-summer-sale', + userId: 'user-alice', + channelId: 'channel-summer-sale', + data: { + classVersion: 1, + payload: { role: 'moderator' }, + }, + }); + console.log('setMembership response:', response.data); + // snippet.end + assert.equal(response.data.payload?.role, 'moderator'); + }); + + // Patch of one payload field. `needs` the update so the starting value is known. + step('patchMembershipBasicUsage', { ...secured, needs: ['updateMembershipBasicUsage'], proves: 'updateMembership patches one payload field.' }, async () => { + const pubnub = world.server; + // snippet.patchMembershipBasicUsage + const response = await pubnub.dataSync.updateMembership({ + id: 'membership-alice-summer-sale', + replace: { '/payload/role': 'moderator' }, + }); + console.log('updateMembership response:', response.data); + // snippet.end + assert.equal(response.data.payload?.role, 'moderator'); + }); + + // ---- entities (class `product`) ---------------------------------------------------------------------------- + + const entity = { ...secured, needs: ['datasyncClassProduct', 'createEntityBasicUsage'] }; + + // Creates the `product` entity, the object most later steps use. Skips with the class precondition. + step('createEntityBasicUsage', { ...secured, needs: ['datasyncClassProduct'], proves: 'createEntity creates the product entity that later steps use.' }, async () => { + const pubnub = world.server; + // snippet.createEntityBasicUsage + const response = await pubnub.dataSync.createEntity({ + id: 'product-sneaker-42', + class: 'product', + data: { + classVersion: 1, + payload: { name: 'Retro Sneaker', price: 89.99, stock: 12 }, + }, + }); + console.log('createEntity response:', response.data); + // snippet.end + assert.equal(response.data.id, product()); + assert.equal(response.data.entityClass, 'product'); + assert.deepEqual(response.data.payload, { name: 'Retro Sneaker', price: 89.99, stock: 12 }); + }); + + // Reads the product by id. + step('getEntityBasicUsage', { ...entity, proves: 'getEntity reads the product by id.' }, async () => { + const pubnub = world.server; + // snippet.getEntityBasicUsage + const response = await pubnub.dataSync.getEntity({ id: 'product-sneaker-42' }); + console.log('getEntity response:', response.data); + // snippet.end + assert.equal(response.data.id, product()); + assert.equal(response.data.payload?.price, 89.99); + }); + + // Lists products sorted by price descending, verified on the returned prices. + step('getEntitiesBasicUsage', { ...entity, proves: 'getEntities lists products sorted by price descending, verified on the returned prices.' }, async () => { + const pubnub = world.server; + // snippet.getEntitiesBasicUsage + const response = await pubnub.dataSync.getEntities({ + class: 'product', + sort: { price: 'desc' }, + limit: 20, + }); + console.log('getEntities response:', response.data); + console.log('getEntities meta:', response.meta); + // snippet.end + assert.ok(ids(response).includes(product())); + const prices = response.data.map((e) => Number(e.payload?.price)); + assert.deepEqual(prices, [...prices].sort((a, b) => b - a), 'sorted by price descending'); + }); + + // Polls until the eventually consistent `filter` finds the product, then runs the snippet. + step('getEntitiesFilterExample', { ...entity, timeout: 60_000, proves: 'getEntities with filter finds the product once the index has caught up.' }, async () => { + const pubnub = world.server; + await eventually(async () => { + const found = await api().getEntities({ class: 'product', filter: 'name LIKE "*sneaker*" && !(status == "discontinued")' }); + assert.ok(ids(found).includes(product())); + }, { timeout: 45_000, label: 'filter index caught up with the new product' }); + // snippet.getEntitiesFilterExample + // Reference a declared property by its `name` (not its `path`), and combine conditions with `&&` + // and `||`. + const response = await pubnub.dataSync.getEntities({ + class: 'product', + filter: 'name LIKE "*sneaker*" && !(status == "discontinued")', + }); + console.log('getEntities (filter) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(product())); + }); + + // Strongly consistent filter on declared properties, no wait. + step('getEntitiesFilterFastExample', { ...entity, proves: 'getEntities with filterFast finds the product at once.' }, async () => { + const pubnub = world.server; + // snippet.getEntitiesFilterFastExample + const response = await pubnub.dataSync.getEntities({ + class: 'product', + filterFast: 'price < 100 && stock > 0', + }); + console.log('getEntities (filterFast) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(product())); + }); + + // Cursor loop over products with console capture. + step('getEntitiesCursorPagingExample', { ...entity, proves: 'The cursor paging loop for products runs and lists the product.' }, async () => { + const pubnub = world.server; + await world.withConsole(async (logs) => { + // snippet.getEntitiesCursorPagingExample + let cursor: string | undefined; + let hasNext = true; + let page = 0; + + while (hasNext) { + const response = await pubnub.dataSync.getEntities({ + class: 'product', + filterFast: 'price < 100', + limit: 20, + cursor, + }); + console.log(`Page ${++page}:`, response.data); + cursor = response.meta?.next_cursor ?? undefined; + hasNext = response.meta?.has_next ?? false; + } + // snippet.end + assert.ok(logs.lines.some((line) => line.includes(product())), 'a page lists the product'); + }); + }); + + // Full replace. Sets the price and stock that later patch steps assert against. + step('updateEntityBasicUsage', { ...entity, proves: 'setEntity replaces the product and sets the price and stock later steps rely on.' }, async () => { + const pubnub = world.server; + // snippet.updateEntityBasicUsage + const response = await pubnub.dataSync.setEntity({ + id: 'product-sneaker-42', + data: { + classVersion: 1, + payload: { name: 'Retro Sneaker', price: 79.99, stock: 8 }, + }, + }); + console.log('setEntity response:', response.data); + // snippet.end + assert.deepEqual(response.data.payload, { name: 'Retro Sneaker', price: 79.99, stock: 8 }); + }); + + // Patch of the price only, stock must stay 8. + step('patchEntityBasicUsage', { ...secured, needs: ['updateEntityBasicUsage'], proves: 'updateEntity patches the price and the stock stays the same.' }, async () => { + const pubnub = world.server; + // snippet.patchEntityBasicUsage + const response = await pubnub.dataSync.updateEntity({ + id: 'product-sneaker-42', + replace: { '/payload/price': 79.99 }, + }); + console.log('updateEntity response:', response.data); + // snippet.end + assert.equal(response.data.payload?.price, 79.99); + assert.equal(response.data.payload?.stock, 8); + }); + + // Mixes all six JSON Patch operations in one call. The patch only applies to a payload that has the touched + // fields, so the step first rewrites the entity with them and stores its eTag in the `entityEtag` placeholder. + // After the snippet it replays the old eTag to prove the guard rejects it (403 on the service, 412 on the page). + step('updateEntityCombinedPatchExample', { ...secured, needs: ['patchEntityBasicUsage'], proves: 'One patch call with all six JSON Patch operations applies, and a stale eTag is rejected.' }, async () => { + const pubnub = world.server; + // The patch needs a payload it can apply to: stock 8 for `test`, an existing `tags` array, `legacy.field` + // to remove and `legacyName` to move. + const seeded = await api().setEntity({ + id: product(), + data: { + classVersion: 1, + payload: { name: 'Retro Sneaker', price: 79.99, stock: 8, tags: [], legacy: { field: 'old' }, legacyName: 'Retro' }, + }, + }); + world.setPh('entityEtag', seeded.data.eTag); + // snippet.updateEntityCombinedPatchExample + // A single `updateEntity` call can mix `add`, `replace`, `remove`, `move`, `copy`, and `test`. Add + // `ifMatchesEtag` (the `eTag` from a prior read) to reject the patch with a 412 if the entity + // changed since you read it, instead of silently overwriting a concurrent change. + const response = await pubnub.dataSync.updateEntity({ + id: 'product-sneaker-42', + test: { '/payload/stock': 8 }, + replace: { '/payload/price': 74.99 }, + add: { '/payload/tags/0': 'clearance' }, + remove: ['/payload/legacy/field'], + move: [{ from: '/payload/legacyName', path: '/payload/displayName' }], + copy: [{ from: '/payload/displayName', path: '/payload/previousName' }], + ifMatchesEtag: 'StUvWxYzAbCdEf', + }); + console.log('updateEntity (combined patch) response:', response.data); + // snippet.end + const payload = response.data.payload as Record; + assert.equal(payload.price, 74.99); + assert.deepEqual(payload.tags, ['clearance']); + assert.deepEqual(payload.legacy, {}); + assert.equal(payload.displayName, 'Retro'); + assert.equal(payload.previousName, 'Retro'); + assert.ok(!('legacyName' in payload), 'move removed the source'); + await assert.rejects( + () => api().updateEntity({ id: product(), replace: { '/payload/price': 70 }, ifMatchesEtag: world.ph('entityEtag') }), + (error: unknown) => /\b(412|403)\b/.test(errorText(error)), + 'the old eTag is rejected after the patch', + ); + }); + + // ---- first-run probes that need no events ------------------------------------------------------------------ + // + // These are harness checks (no snippet markers). They settle questions that the event steps depend on, so a failure + // of an event step can be told apart from a wrong assumption about ids or timing. + + /** + * The pattern samples use entity ids with a dot (`product.676`) and subscribe to `product.*`. The wildcard needs a + * dot in the id, so first prove the service stores such ids, returns them verbatim, and filters on them. + */ + check('datasyncDotIds', { ...secured, needs: ['datasyncClassProduct'], proves: 'Entity ids with a dot are stored verbatim, returned verbatim and found by filterFast.' }, async () => { + const id = world.n('product.676'); + try { + const created = await api().createEntity({ id, class: 'product', data: { classVersion: 1, payload: { name: 'Dot Sneaker', price: 10, stock: 1 } } }); + assert.equal(created.data.id, id, 'the service keeps a dotted id verbatim'); + assert.equal((await api().getEntity({ id })).data.id, id); + // `id` is a built-in filterable field, so a strongly consistent filter finds the dotted id right away. + const found = await api().getEntities({ class: 'product', filterFast: `id == "${id}"` }); + assert.deepEqual(ids(found), [id]); + } finally { + await api().removeEntity({ id }).catch(() => undefined); + } + }); + + /** + * `addDataSyncListener` deletes the observed product and creates it again with the same id so the later steps can + * use it. Find out whether the service accepts the create straight after the delete, so the step does not need a + * retry loop that would hide a real problem. A failure here prints what the service said. + */ + check('datasyncDeleteRecreate', { ...secured, needs: ['datasyncClassProduct'], proves: 'An entity can be created again with the same id right after it was deleted.' }, async () => { + // (Used by addDataSyncListener, which recreates the product it deletes without a retry loop.) + const id = world.n('product.678'); + const body = { classVersion: 1, payload: { name: 'Recreate Sneaker', price: 12, stock: 2 } }; + try { + await api().createEntity({ id, class: 'product', data: body }); + await api().removeEntity({ id }); + const again = await api().createEntity({ id, class: 'product', data: body }); + assert.equal(again.data.id, id, 'the id can be reused straight after the delete'); + } finally { + await api().removeEntity({ id }).catch(() => undefined); + } + }); + + /** + * The four fragment blocks of the page (filter and sort options, patch forms) are not snippets. This runs every + * line of fixtures/datasync-fragments.ts against the service so none of them is only decoration. + * + * Filters run against class `product`. `filterFast` is strongly consistent and runs at once. `filter` runs on + * eventually consistent storage, so each of those is polled until the service accepts it. A 400 is not retried: + * it means the expression is invalid, which is what this check exists to find. + */ + check('datasyncFragments', { ...secured, needs: ['datasyncClassProduct'], timeout: 120_000, proves: 'Every filter, sort and patch line shown in the DataSync page fragments is accepted or refused by the service as documented.' }, async () => { + const accepted = async (query: Pick): Promise => { + await api().getEntities({ ...query, class: 'product', limit: 5 }); + }; + for (const fragment of filterFragments) { + await accepted(fragment).catch((error) => { + throw new Error(`filter fragment ${JSON.stringify(fragment)} was rejected: ${errorText(error)}`); + }); + } + // Two sort fragments order by `type`, a property the sample class `product` does not declare (the docs example + // implies a class that has both `type` and `price`). The service refuses a sort field that no property declares, + // with 403 from the Access Manager service (the same answer as for an undeclared projection). That is a docs + // example written for a different class, not a syntax error, so the check expects the refusal and keeps the + // finding visible. Every other sort fragment must be accepted. + const needsUndeclaredType = (fragment: (typeof sortFragments)[number]): boolean => JSON.stringify(fragment.sort).includes('type'); + for (const fragment of sortFragments) { + if (needsUndeclaredType(fragment)) { + await assert.rejects(accepted(fragment), (error: unknown) => /\b(400|403)\b/.test(errorText(error)), `undeclared sort field ${JSON.stringify(fragment)}`); + } else { + await accepted(fragment).catch((error) => { + throw new Error(`sort fragment ${JSON.stringify(fragment)} was rejected: ${errorText(error)}`); + }); + } + } + + // Patch forms: apply each line to a scratch entity that holds every field the lines touch. + const id = world.n('product.679'); + try { + await api().createEntity({ + id, + class: 'product', + data: { + classVersion: 1, + status: 'active', + payload: { name: 'Patch Sneaker', price: 99, stock: 3, tags: [], tempFlag: true, legacy: { field: 'old' }, legacyName: 'Retro', displayName: 'Retro' }, + }, + }); + for (const fragment of patchFragments) { + const response = await api().updateEntity({ id, ...fragment }).catch((error) => { + throw new Error(`patch fragment ${JSON.stringify(fragment)} was rejected: ${errorText(error)}`); + }); + assert.equal(response.status, 200, `patch fragment ${JSON.stringify(fragment)}`); + } + // Spot check the result of the chain: add, replace, remove, move and copy all took effect. + const payload = (await api().getEntity({ id })).data.payload as Record; + assert.deepEqual(payload.tags, ['featured']); + assert.equal(payload.price, 79.99); + assert.ok(!('tempFlag' in payload)); + assert.ok(!('legacyName' in payload)); + assert.equal(payload.previousName, 'Retro'); + } finally { + await api().removeEntity({ id }).catch(() => undefined); + } + }); + + // ---- real-time events (subscribe page samples and the datasync page sample) --------------------------------- + + const eventStep = { ...secured, needs: ['datasyncClassProduct', 'createEntityBasicUsage', 'datasyncEvents'], timeout: 60_000 }; + const logged = (lines: string[], text: string): boolean => lines.some((line) => line.includes(text)); + + // Subscribe-page sample. `connected` is registered before the snippet subscribes because statuses are not + // replayed. The event check happens only after `skipUnlessEvents`, so with event rules off the step still proves + // the subscription connects. + step('dataSyncEntitySubscribeExample', { ...eventStep, proves: 'The entity subscribe sample connects, and a changed entity arrives as an event when event rules are on.' }, async () => { + // The probe result is cached, so this decides the skip up front but the skip itself is thrown only after the + // subscription connected (below). + const off = await offKinds('entity'); + const pubnub = world.server; + await world.withConsole(async (logs) => { + const ready = connected(pubnub, { channels: [product()] }); + // snippet.dataSyncEntitySubscribeExample + // DataSync objects publish create/update/delete events over a `dataSync` listener. Create a + // DataSync SDK entity for the object, subscribe to it, and attach the listener. + const productSubscription = pubnub.dataSyncEntity('product-sneaker-42').subscription(); + productSubscription.onDataSync = (event) => console.log(event.message.event, event.message.data); + productSubscription.subscribe(); + // snippet.end + await ready; + try { + skipUnlessEvents(off); + await api().updateEntity({ id: product(), replace: { '/payload/stock': 7 } }); + await eventually(() => assert.ok(logged(logs.lines, 'update') && logged(logs.lines, product())), { label: 'update event logged' }); + } finally { + productSubscription.unsubscribe(); + } + }); + }); + + // Subscribe-page sample that observes one entity. Same connect-first, skip-if-no-events structure. Asserts the new + // stock arrives in the event payload. + step('subscribeToDataSyncEntity', { ...eventStep, proves: 'Subscribing to one entity delivers the new stock in the event payload when event rules are on.' }, async () => { + const off = await offKinds('entity'); + const pubnub = world.server; + await world.withConsole(async (logs) => { + const ready = connected(pubnub, { channels: [product()] }); + // snippet.subscribeToDataSyncEntity + // Observe one entity. + const entitySubscription = pubnub.dataSyncEntity('product-sneaker-42').subscription(); + + entitySubscription.onDataSync = (event) => { + console.log(event.message.event, event.message.data); + }; + + entitySubscription.subscribe(); + // snippet.end + await ready; + try { + skipUnlessEvents(off); + await api().updateEntity({ id: product(), replace: { '/payload/stock': 6 } }); + await eventually(() => assert.ok(logs.lines.some((l) => l.startsWith('update') && l.includes('"stock":6'))), { label: 'update event with the new stock' }); + } finally { + entitySubscription.unsubscribe(); + } + }); + }); + + // Wildcard subscription on the `admin` projection channel (`__admin__`). Creates a dotted id that matches the + // pattern and waits for its event. Cleans up the extra entity in `finally`. + step('observeDataSyncEntitiesByPattern', { ...eventStep, proves: 'A wildcard subscription on the admin projection channel receives the event for a newly created dotted id.' }, async () => { + const off = await offKinds('entity'); + const pubnub = world.server; + const member = world.n('product.676'); + await world.withConsole(async (logs) => { + const ready = connected(pubnub, { channels: [`__admin__${world.n('product.*')}`] }); + // snippet.observeDataSyncEntitiesByPattern + // Because the identifier is used verbatim, a wildcard identifier subscribes to the matching + // channel pattern and one subscription serves the whole set. `event.channel` reports the concrete + // delivery channel, while `event.subscription` reports the pattern that matched it. + const patternSubscription = pubnub.dataSyncEntity('product.*').subscription({ projection: 'admin' }); + + patternSubscription.onDataSync = (event) => { + // event.channel -> '__admin__product.676' + // event.subscription -> '__admin__product.*' + console.log('changed:', event.message.data.id); + }; + + patternSubscription.subscribe(); + // snippet.end + await ready; + try { + skipUnlessEvents(off); + await api().createEntity({ id: member, class: 'product', data: { classVersion: 1, payload: { name: 'Pattern Sneaker', price: 10, stock: 1 } } }); + await eventually(() => assert.ok(logged(logs.lines, `changed: ${member}`)), { label: 'event for an entity that matches the pattern' }); + } finally { + patternSubscription.unsubscribe(); + await api().removeEntity({ id: member }).catch(() => undefined); + } + }); + }); + + // Combines a user and an entity subscription into one set. Needs events on for both kinds. Triggers one update of + // each and expects both on the one listener. + step('combineDataSyncSubscriptions', { ...eventStep, proves: 'A user and an entity subscription combined in one set both deliver events to one listener.' }, async () => { + const off = await offKinds('entity', 'user'); + const pubnub = world.server; + const member = world.n('product.677'); + await world.withConsole(async (logs) => { + const ready = connected(pubnub, { channels: [world.n('product.*'), user()] }); + // snippet.combineDataSyncSubscriptions + // A DataSync subscription is an ordinary `subscription`, so it composes into a set with any other + // subscription. + const productsSubscription = pubnub.dataSyncEntity('product.*').subscription(); + const aliceSubscription = pubnub.dataSyncUser('user-alice').subscription(); + + const dataSyncSubscriptionSet = productsSubscription.addSubscription(aliceSubscription); + dataSyncSubscriptionSet.onDataSync = (event) => console.log(event.message.objectType, event.message.event); + dataSyncSubscriptionSet.subscribe(); + // snippet.end + await ready; + try { + skipUnlessEvents(off); + await api().updateUser({ id: user(), replace: { '/payload/name': 'Alice C.' } }); + await api().createEntity({ id: member, class: 'product', data: { classVersion: 1, payload: { name: 'Set Sneaker', price: 11, stock: 1 } } }); + await eventually(() => assert.ok(logs.lines.includes('user update') && logs.lines.includes('entity create')), { label: 'one set receives user and entity events' }); + } finally { + dataSyncSubscriptionSet.unsubscribe(); + await api().removeEntity({ id: member }).catch(() => undefined); + } + }); + }); + + // Exercises all three branches of the sample's `switch`: update, then delete, then create. Deleting and recreating + // the product with the same id is safe because the check `datasyncDeleteRecreate` proved the service allows it, + // and the product is back for the steps that follow. + step('addDataSyncListener', { ...eventStep, proves: 'The listener switch sees update, delete and create events in that order for one product.' }, async () => { + const off = await offKinds('entity'); + const pubnub = world.server; + await world.withConsole(async (logs) => { + const ready = connected(pubnub, { channels: [product()] }); + // snippet.addDataSyncListener + // Create a DataSync SDK entity for the object and attach the listener. + const dataSyncObjectId = 'product-sneaker-42'; + const dataSyncListenerSubscription = pubnub.dataSyncEntity(dataSyncObjectId).subscription(); + + dataSyncListenerSubscription.onDataSync = (event) => { + const change = event.message; + + // This channel can also receive relationship events and events about connected entities. Only + // handle events for the observed product. `Product` is a class of your own, so its events + // report `type: 'entity'`. + if (change.type !== 'entity' || change.data.id !== dataSyncObjectId) return; + + switch (change.event) { + case 'create': + console.log('Product created:', change.data.id); + break; + case 'update': { + // `payload` is typed as the generic SDK `Payload` union (it can be a string, number, array, + // and so on for other message types), but a DataSync entity payload is always the JSON + // object declared by its class, so it is safe to read it as a record here. + const payload = + 'payload' in change.data ? (change.data.payload as Record | undefined) : undefined; + console.log('New price:', payload?.price); + break; + } + case 'delete': + console.log('Product deleted:', change.data.id); + break; + } + }; + + dataSyncListenerSubscription.subscribe(); + // snippet.end + await ready; + try { + skipUnlessEvents(off); + await api().updateEntity({ id: product(), replace: { '/payload/price': 69.5 } }); + await eventually(() => assert.ok(logged(logs.lines, 'New price: 69.5')), { label: 'update branch' }); + // Delete and recreate, which covers the other two branches and leaves the product for the later steps. + await api().removeEntity({ id: product() }); + await eventually(() => assert.ok(logged(logs.lines, `Product deleted: ${product()}`)), { label: 'delete branch' }); + // The check datasyncDeleteRecreate proved that the service accepts the same id straight after a delete, so no retry + // loop here: a failure should show up as a failure. + await api().createEntity({ id: product(), class: 'product', data: { classVersion: 1, payload: { name: 'Retro Sneaker', price: 74.99, stock: 8 } } }); + await eventually(() => assert.ok(logged(logs.lines, `Product created: ${product()}`)), { label: 'create branch' }); + } finally { + dataSyncListenerSubscription.unsubscribe(); + } + }); + }); + + // The projection sample of the "DataSync SDK entities" section of the subscribe page. It only builds two + // subscriptions, so it needs no event rules. The harness then subscribes both and checks the two data channels. + step('dataSyncProjectionSubscriptions', { ...secured, proves: 'Two projection subscriptions connect, and unsubscribing one leaves the other subscribed.' }, async () => { + const pubnub = world.server; + // snippet.dataSyncProjectionSubscriptions + const user = pubnub.dataSyncUser('user-alice') + + const base = user.subscription() // observes `user-alice` + const admin = user.subscription({ projection: 'admin' }) // observes `__admin__user-alice` + // snippet.end + // The docs promise two independent data channels: the object itself and `____`. + const ready = connected(pubnub, { channels: [world.n('user-alice'), `__admin__${world.n('user-alice')}`] }); + base.subscribe(); + admin.subscribe(); + await ready; + // "Unsubscribing one leaves the other subscribed": drop the base one and check the admin channel is still active. + base.unsubscribe(); + assert.ok(pubnub.getSubscribedChannels().includes(`__admin__${world.n('user-alice')}`), 'the admin projection stays subscribed'); + assert.ok(!pubnub.getSubscribedChannels().includes(world.n('user-alice')), 'the base channel is gone'); + admin.unsubscribe(); + }); + + // ---- relationships (class `ProductOwner`) ------------------------------------------------------------------ + + const link = { ...secured, needs: ['datasyncClassProductOwner', 'createRelationshipBasicUsage'] }; + + // Fixture, no snippet. Creates Bob as a user, the owning end of the `ProductOwner` link. Bob is a DataSync user, + // not an Access Manager token holder. + check('datasyncSellerFixture', { ...secured, needs: ['datasyncClassProductOwner', 'createEntityBasicUsage'], proves: 'Fixture: Bob exists as a DataSync user, the owning end of the ProductOwner link.' }, async () => { + const created = await api().createUser({ id: seller(), data: { classVersion: 1, payload: { name: 'Bob', type: 'seller' } } }); + assert.equal(created.data.id, seller()); + }); + + // Creates the `ProductOwner` link from the seller to the product with the fixed id that the later samples use. + // `needs` the seller fixture because both ends must exist. + step('createRelationshipBasicUsage', { ...secured, needs: ['datasyncSellerFixture'], proves: 'createRelationship links the seller to the product with the fixed id that later steps use.' }, async () => { + const pubnub = world.server; + // snippet.createRelationshipBasicUsage + const response = await pubnub.dataSync.createRelationship({ + id: 'rel-bob-owns-sneaker-42', + class: 'ProductOwner', + entityAId: 'seller-bob', + entityBId: 'product-sneaker-42', + data: { + classVersion: 1, + payload: { since: '2026-07-13' }, + }, + }); + console.log('createRelationship response:', response.data); + // snippet.end + assert.equal(response.data.id, relationship()); + assert.equal(response.data.relationshipClass, 'ProductOwner'); + assert.equal(response.data.entityAId, seller()); + assert.equal(response.data.entityBId, product()); + assert.deepEqual(response.data.payload, { since: '2026-07-13' }); + }); + + // Reads the link by its fixed id. + step('getRelationshipBasicUsage', { ...link, proves: 'getRelationship reads the link by its fixed id.' }, async () => { + const pubnub = world.server; + // snippet.getRelationshipBasicUsage + const response = await pubnub.dataSync.getRelationship({ + id: 'rel-bob-owns-sneaker-42', + }); + console.log('getRelationship response:', response.data); + // snippet.end + assert.equal(response.data.id, relationship()); + assert.equal(response.data.entityBId, product()); + }); + + // Lists links from the seller side, expects exactly the fixture. + step('getRelationshipsBasicUsage', { ...link, proves: 'getRelationships lists exactly the fixture link from the seller side.' }, async () => { + const pubnub = world.server; + // snippet.getRelationshipsBasicUsage + const response = await pubnub.dataSync.getRelationships({ + class: 'ProductOwner', + entityAId: 'seller-bob', + limit: 20, + }); + console.log('getRelationships response:', response.data); + console.log('getRelationships meta:', response.meta); + // snippet.end + assert.deepEqual(ids(response), [relationship()]); + }); + + // Same list from the product side. + step('getRelationshipsByEntityBIdExample', { ...link, proves: 'getRelationships by entity B id lists the same link from the product side.' }, async () => { + const pubnub = world.server; + // snippet.getRelationshipsByEntityBIdExample + // Pass `entityBId` instead of `entityAId` to list relationships where the entity is on the second + // side of the link. + const response = await pubnub.dataSync.getRelationships({ + class: 'ProductOwner', + entityBId: 'product-sneaker-42', + limit: 20, + }); + console.log('getRelationships (entityBId) response:', response.data); + // snippet.end + assert.deepEqual(ids(response), [relationship()]); + }); + + // Full replace that adds a `tier` to the payload, which the filter steps then query. + step('updateRelationshipBasicUsage', { ...link, proves: 'setRelationship replaces the link and adds a tier to its payload.' }, async () => { + const pubnub = world.server; + // snippet.updateRelationshipBasicUsage + const response = await pubnub.dataSync.setRelationship({ + id: 'rel-bob-owns-sneaker-42', + entityAId: 'seller-bob', + entityBId: 'product-sneaker-42', + data: { + classVersion: 1, + payload: { since: '2026-07-13', tier: 'gold' }, + }, + }); + console.log('setRelationship response:', response.data); + // snippet.end + assert.deepEqual(response.data.payload, { since: '2026-07-13', tier: 'gold' }); + }); + + // Negated eventually consistent filter. Polls until the link is indexed. Tier is `gold` at this point, so the link + // must be kept. + step('getRelationshipsFilterExample', { ...secured, needs: ['updateRelationshipBasicUsage'], timeout: 60_000, proves: 'A negated filter keeps the gold link once the index has caught up.' }, async () => { + const pubnub = world.server; + await eventually(async () => { + const found = await api().getRelationships({ class: 'ProductOwner', filter: '!(tier == "platinum")' }); + assert.ok(ids(found).includes(relationship())); + }, { timeout: 45_000, label: 'filter index caught up with the relationship' }); + // snippet.getRelationshipsFilterExample + const response = await pubnub.dataSync.getRelationships({ + class: 'ProductOwner', + filter: '!(tier == "platinum")', + }); + console.log('getRelationships (filter) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(relationship()), 'tier is gold, so the negated filter keeps it'); + }); + + // Strongly consistent filter on `tier`, no wait. + step('getRelationshipsFilterFastExample', { ...secured, needs: ['updateRelationshipBasicUsage'], proves: 'filterFast on tier finds the link at once.' }, async () => { + const pubnub = world.server; + // snippet.getRelationshipsFilterFastExample + const response = await pubnub.dataSync.getRelationships({ + class: 'ProductOwner', + filterFast: 'tier == "gold"', + }); + console.log('getRelationships (filterFast) response:', response.data); + // snippet.end + assert.ok(ids(response).includes(relationship())); + }); + + // Cursor loop over the seller's links with console capture. + step('getRelationshipsCursorPagingExample', { ...secured, needs: ['updateRelationshipBasicUsage'], proves: 'The cursor paging loop for relationships runs and lists the link.' }, async () => { + const pubnub = world.server; + await world.withConsole(async (logs) => { + // snippet.getRelationshipsCursorPagingExample + let cursor: string | undefined; + let hasNext = true; + let page = 0; + + while (hasNext) { + const response = await pubnub.dataSync.getRelationships({ + class: 'ProductOwner', + entityAId: 'seller-bob', + limit: 20, + cursor, + }); + console.log(`Page ${++page}:`, response.data); + cursor = response.meta?.next_cursor ?? undefined; + hasNext = response.meta?.has_next ?? false; + } + // snippet.end + assert.ok(logs.lines.some((line) => line.includes(relationship())), 'a page lists the relationship'); + }); + }); + + // Patch of the tier. Runs after the filter steps, which rely on tier `gold`. + step('patchRelationshipBasicUsage', { ...secured, needs: ['updateRelationshipBasicUsage'], proves: 'updateRelationship patches the tier of the link.' }, async () => { + const pubnub = world.server; + // snippet.patchRelationshipBasicUsage + const response = await pubnub.dataSync.updateRelationship({ + id: 'rel-bob-owns-sneaker-42', + replace: { '/payload/tier': 'platinum' }, + }); + console.log('updateRelationship response:', response.data); + // snippet.end + assert.equal(response.data.payload?.tier, 'platinum'); + assert.equal(response.data.payload?.since, '2026-07-13'); + }); + + // ---- cursor paging, harness side ---------------------------------------------------------------------------- + + // Harness side of paging: pages by one so `next_cursor`, `has_next` and no-duplicates are really exercised, which + // the page-size-20 samples never do. + check('datasyncCursorPaging', { ...secured, needs: ['createUserBasicUsage', 'createDataSyncChannelBasicUsage'], proves: 'Paging one item at a time returns next_cursor and has_next correctly and never repeats an item.' }, async () => { + // The samples ask for 20 per page and the keyset holds a handful of objects, so they never leave page one. + // Page by one over the two objects of this run to prove `next_cursor` and `has_next` work as described. + const seen: string[] = []; + let cursor: string | undefined; + let pages = 0; + for (;;) { + const response = await api().getChannels({ filterFast: 'type == "promotion"', limit: 1, cursor }); + pages += 1; + seen.push(...ids(response)); + cursor = response.meta?.next_cursor ?? undefined; + if (!response.meta?.has_next) break; + assert.ok(cursor, 'has_next true comes with a next_cursor'); + assert.ok(pages < 50, 'paging terminates'); + } + assert.ok(seen.includes(channel()), 'paging by one still reaches the channel'); + assert.equal(new Set(seen).size, seen.length, 'no object appears twice'); + }); + + // ---- removal, last -------------------------------------------------------------------------------------------- + + // Removal runs last and in dependency order (link, membership, entity, channel, user), so earlier steps can rely + // on the fixtures. Each step proves the 404 afterwards. + step('removeRelationshipBasicUsage', { ...secured, needs: ['patchRelationshipBasicUsage'], proves: 'removeRelationship deletes the link and a read afterwards answers 404.' }, async () => { + const pubnub = world.server; + // snippet.removeRelationshipBasicUsage + const response = await pubnub.dataSync.removeRelationship({ + id: 'rel-bob-owns-sneaker-42', + }); + console.log('removeRelationship response status:', response.status); + // snippet.end + assert.ok(response.status >= 200 && response.status < 300); + await assert.rejects(() => api().getRelationship({ id: relationship() }), (error: unknown) => /404/.test(errorText(error))); + }); + + // Removes the membership, then proves it answers 404. + step('removeMembershipBasicUsage', { ...secured, needs: ['patchMembershipBasicUsage'], proves: 'removeMembership deletes the membership and a read afterwards answers 404.' }, async () => { + const pubnub = world.server; + // snippet.removeMembershipBasicUsage + const response = await pubnub.dataSync.removeMembership({ + id: 'membership-alice-summer-sale', + }); + console.log('removeMembership response status:', response.status); + // snippet.end + assert.ok(response.status >= 200 && response.status < 300); + await assert.rejects(() => api().getMembership({ id: membership() }), (error: unknown) => /404/.test(errorText(error))); + }); + + // Removes the product (also removes its links), then proves 404. + step('removeEntityBasicUsage', { ...secured, needs: ['datasyncClassProduct', 'createEntityBasicUsage'], proves: 'removeEntity deletes the product and a read afterwards answers 404.' }, async () => { + const pubnub = world.server; + // snippet.removeEntityBasicUsage + const response = await pubnub.dataSync.removeEntity({ id: 'product-sneaker-42' }); + console.log('removeEntity response status:', response.status); + // snippet.end + assert.ok(response.status >= 200 && response.status < 300); + await assert.rejects(() => api().getEntity({ id: product() }), (error: unknown) => /404/.test(errorText(error))); + }); + + // Removes the channel, then proves 404. + step('removeChannelBasicUsage', { ...secured, needs: ['createDataSyncChannelBasicUsage'], proves: 'removeChannel deletes the DataSync channel and a read afterwards answers 404.' }, async () => { + const pubnub = world.server; + // snippet.removeChannelBasicUsage + const response = await pubnub.dataSync.removeChannel({ id: 'channel-summer-sale' }); + console.log('removeChannel response status:', response.status); + // snippet.end + assert.ok(response.status >= 200 && response.status < 300); + await assert.rejects(() => api().getChannel({ id: channel() }), (error: unknown) => /404/.test(errorText(error))); + }); + + // Removes Alice (the janitor also sweeps by run suffix), then proves 404. + step('removeUserBasicUsage', { ...secured, needs: ['createUserBasicUsage'], proves: 'removeUser deletes Alice and a read afterwards answers 404.' }, async () => { + const pubnub = world.server; + // snippet.removeUserBasicUsage + const response = await pubnub.dataSync.removeUser({ id: 'user-alice' }); + console.log('removeUser response status:', response.status); + // snippet.end + assert.ok(response.status >= 200 && response.status < 300); + await assert.rejects(() => api().getUser({ id: user() }), (error: unknown) => /404/.test(errorText(error))); + }); + + // Removes the one fixture no sample removes (Bob). + check('datasyncTeardown', { ...secured, proves: 'Teardown removes the one fixture no sample removes (Bob).' }, async () => { + // Fixtures that no sample removes. The janitor also sweeps by run suffix, this keeps the keyset tidy on success. + await api().removeUser({ id: seller() }).catch(() => undefined); + }); +}); diff --git a/docs-snippets/api-reference/encryption-legacy.ts b/docs-snippets/api-reference/encryption-legacy.ts new file mode 100644 index 000000000..6d4ce1271 --- /dev/null +++ b/docs-snippets/api-reference/encryption-legacy.ts @@ -0,0 +1,74 @@ +import assert from '../harness/assert.js'; +import PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; + +/** + * docs/sdks/javascript/api-reference/encryption-legacy.md + * + * Deprecated `pubnub.encrypt()` and `pubnub.decrypt()` plus the cipher key arguments. Local work on the open + * keyset clients. The only embedded sample is `decryptBasicUsage`, the page's other code blocks are signatures, + * so the rest is asserted in checks. Both deprecated methods log a warning on every call, which is expected. + * + * Keyset: open. Phase: P8 (legacy pages). Sneaker Drop story: an old client still decrypts order notes with a + * cipher key argument. Proves: the real behavior of the deprecated methods, including the surprises the checks + * record as docs notes (object payloads throw, a wrong key returns null). Deliberately does not prove: the + * modern crypto module (encryption.ts) or any network traffic. + */ +export default page('api-reference/encryption-legacy', 'P8', (world) => { + // Open keyset client with an AES-CBC crypto module, the setup the legacy methods fall back to. + const withModule = (label: string): PubNub => + world.client('open', label, { cryptoModule: PubNub.CryptoModule.aesCbcCryptoModule({ cipherKey: 'pubnubenigma' }) }); + + // The only embedded sample of the page. Encrypting happens before the snippet (outside the markers) so the snippet + // can show just decrypt(). The plain text is JSON because decrypt() JSON-parses the result. + step('decryptBasicUsage', { proves: 'The legacy decrypt returns the original JSON for text encrypted with the same cipher key.' }, () => { + const pubnub = withModule('legacy-decrypt'); + // `decrypt()` JSON-parses the plain text, so the input has to be a JSON document. + const encrypted = pubnub.encrypt(JSON.stringify({ item: 'sneaker' })) as string; + // snippet.decryptBasicUsage + const decrypted = pubnub.decrypt(encrypted); // Pass the encrypted data as the first parameter in decrypt Method + // snippet.end + assert.deepEqual(decrypted, { item: 'sneaker' }); + }); + + // Records the behavior of the optional customCipherKey argument. Each assertion below is a fact the page has to + // state or the signature has to match. + check('encryptionLegacyCipherKeyArguments', { proves: 'The optional customCipherKey argument of encrypt and decrypt behaves as the page states.' }, () => { + const pubnub = withModule('legacy-cipher-key'); + const secret = { item: 'sneaker', size: 42 }; + // customCipherKey overrides the configured module and uses the legacy encryption. + const encrypted = pubnub.encrypt(JSON.stringify(secret), 'another-key'); + assert.equal(typeof encrypted, 'string'); + // The signature on the page takes `string | Payload`, but an object payload throws (live finding). + assert.throws(() => pubnub.encrypt(secret as never, 'another-key'), TypeError); + assert.deepEqual(pubnub.decrypt(encrypted, 'another-key'), secret); + // A wrong key does not throw with the legacy encryption: decrypt() returns null. + assert.equal(pubnub.decrypt(encrypted, 'wrong-key'), null); + // Without a configured module or a key, the data comes back unchanged. + const bare = world.client('open', 'legacy-bare'); + assert.equal(bare.encrypt('plain'), 'plain'); + }); + + // The error row of the decrypt section: unreadable input throws instead of returning garbage. + check('encryptionLegacyDecryptError', { proves: 'Decrypting unreadable input throws instead of returning garbage.' }, () => { + const pubnub = withModule('legacy-decrypt-error'); + // cryptoModule rejects data it cannot read with an Error that describes the reason. + assert.throws(() => pubnub.decrypt(''), /Decryption error/); + }); + + // Legacy file encryption with an explicit key argument. The last assertion records a docs bug candidate: the + // method needs a configured module even though the key argument does the work. + check('encryptionLegacyFileCipherKey', { proves: 'Legacy file encryption with an explicit key works, and records that it still needs a configured module.' }, async () => { + const pubnub = world.client('open', 'legacy-file', { cipherKey: 'pubnubenigma' }); + const content = Buffer.from('legacy file content'); + const file = pubnub.File.create({ data: content, name: 'legacy.txt', mimeType: 'text/plain' }); + const encrypted = await pubnub.encryptFile('file-key', file); + assert.notDeepEqual(Buffer.from(await encrypted.toArrayBuffer()), content); + const decrypted = await pubnub.decryptFile('file-key', encrypted); + assert.deepEqual(Buffer.from(await decrypted.toArrayBuffer()), content); + // The page: decryptFile(key, file) rejects when the client has no cryptoModule, even though the legacy + // decryption does not use it. A client with only a cipherKey has one built in, so this one has none. + const noModule = world.client('open', 'legacy-file-none'); + await assert.rejects(noModule.decryptFile('file-key', encrypted), /File decryption error\. Crypto module not configured\./); + }); +}); diff --git a/docs-snippets/api-reference/encryption.ts b/docs-snippets/api-reference/encryption.ts new file mode 100644 index 000000000..6ecf22336 --- /dev/null +++ b/docs-snippets/api-reference/encryption.ts @@ -0,0 +1,125 @@ +import assert from '../harness/assert.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; + +/** + * docs/sdks/javascript/api-reference/encryption.md + * + * Local work: nothing goes over the network, but the clients need keys, so they use the open keyset. The page + * also embeds `configurationCryptoModule` and the two init snippets, which the configuration and shared modules + * already run. The file samples read `./cat_picture.jpg` from the working directory, so the steps change into a + * temporary directory that holds a random 2 KiB "picture" and restore the old directory afterwards. + * + * Keyset: open. Phase: P4. Sneaker Drop story: a shopper encrypts and decrypts a product photo before sharing it. + * Proves: the AES-CBC crypto module round trip for text and for files, and the documented error table. + * Deliberately does not prove: encrypted publish or subscribe traffic (the publish and subscribe modules use the + * crypto module snippet) and interop with other SDKs' cipher text. + */ +export default page('api-reference/encryption', 'P4', (world) => { + // One scratch directory for the whole page: the encrypt step writes a file the decrypt step reads back. + // It sits outside the repo, so the tracked fixture is never touched. + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pn-docs-encryption-')); + const picture = crypto.randomBytes(2048); + + /** Runs `fn` with `workDir` as the working directory, as the docs file samples assume. */ + const inWorkDir = async (fn: () => Promise): Promise => { + const previous = process.cwd(); + process.chdir(workDir); + try { + return await fn(); + } finally { + process.chdir(previous); + } + }; + + // Open keyset client with a crypto module. `label` becomes the user id (run-suffixed by world.client). + const cryptoClient = (label: string): PubNub => + world.client('open', label, { cryptoModule: PubNub.CryptoModule.aesCbcCryptoModule({ cipherKey: 'pubnubenigma' }) }); + + // Pure local crypto: no network, no wait. After the closing marker it checks the cipher text differs from the + // plain text and that the same module reads it back. decrypt() returns an ArrayBuffer of the JSON bytes, not a string. + step('encryptMessageBasicUsage', { proves: 'encrypt turns a message into cipher text that differs from the plain text, and decrypt reads it back.' }, () => { + // snippet.encryptMessageBasicUsage + // Create a crypto module instance with AES-CBC encryption + const cryptoModule = PubNub.CryptoModule.aesCbcCryptoModule({ + cipherKey: 'pubnubenigma', + }); + + const msgContent = 'This is the data I wish to encrypt.'; + console.log(`Original Message: ${msgContent}`); + + // Encrypt the message + const encryptedMessage = cryptoModule.encrypt(JSON.stringify(msgContent)); + console.log('message encrypted successfully'); + + // Decrypt with the same instance. decrypt() returns an ArrayBuffer with the bytes of the JSON string + const decryptedBytes = cryptoModule.decrypt(encryptedMessage) as ArrayBuffer; + const decryptedMessage = JSON.parse(new TextDecoder().decode(decryptedBytes)); + console.log(`Decrypted Message: ${decryptedMessage}`); + // snippet.end + assert.notEqual(encryptedMessage, JSON.stringify(msgContent), 'the output is not the plain text'); + // decrypt() returns the bytes of the JSON string that went in, not the string (live finding). + assert.ok(decryptedBytes instanceof ArrayBuffer, 'decrypt returns an ArrayBuffer'); + assert.equal(decryptedMessage, msgContent, 'the decoded bytes are the original message'); + }); + + // Writes the random picture into the scratch dir because the docs sample reads './cat_picture.jpg'. Afterwards it + // saves the cipher text as './cat_picture_encrypted.jpg' for the next step, which is why that step `needs` this one. + step('encryptFileBasicUsage', { timeout: 30_000, proves: 'encryptFile produces an encrypted copy of the picture that is saved for the decrypt step.' }, async () => { + const pubnub = cryptoClient('crypto-file'); + fs.writeFileSync(path.join(workDir, 'cat_picture.jpg'), picture); + await inWorkDir(async () => { + // snippet.encryptFileBasicUsage + // Node.js example + // import fs from 'fs'; + + const fileBuffer = fs.readFileSync('./cat_picture.jpg'); + + const file = pubnub.File.create({ data: fileBuffer, name: 'cat_picture.jpg', mimeType: 'image/jpeg' }); + + const encryptedFile = await pubnub.encryptFile(file); + // snippet.end + const encrypted = Buffer.from(await encryptedFile.toArrayBuffer()); + assert.notDeepEqual(encrypted, picture, 'the file content changed'); + assert.ok(encrypted.length > picture.length, 'the cipher text carries a header and padding'); + // The next step reads this file, as the docs sample does. + fs.writeFileSync('./cat_picture_encrypted.jpg', encrypted); + }); + }); + + // Reads the file the previous step wrote and proves a byte-for-byte round trip with a second client that has the + // same cipher key (the key, not the client, decides the result). + step('decryptFileBasicUsage', { needs: ['encryptFileBasicUsage'], timeout: 30_000, proves: 'decryptFile restores the picture byte for byte with a second client that has the same cipher key.' }, async () => { + const pubnub = cryptoClient('crypto-file-reader'); + await inWorkDir(async () => { + // snippet.decryptFileBasicUsage + // Node.js example. + // import fs from 'fs'; + const fileBufferData = fs.readFileSync('./cat_picture_encrypted.jpg'); + + const fileData = pubnub.File.create({ data: fileBufferData, name: 'cat_picture.jpg', mimeType: 'image/jpeg' }); + + const decryptedFile = await pubnub.decryptFile(fileData); + // snippet.end + const decrypted = Buffer.from(await decryptedFile.toArrayBuffer()); + assert.deepEqual(decrypted, picture, 'the round trip returns the original bytes'); + }); + }); + + // Harness-only check, no snippet. Each assertion mirrors a row of the error table or the prose of the file sections. + // Error table of the Encrypt file and Decrypt file sections. + check('encryptionFileErrors', { proves: 'Each row of the file encryption error table is raised for the matching bad call.' }, async () => { + const withModule = cryptoClient('crypto-errors'); + const file = withModule.File.create({ data: Buffer.from('abc'), name: 'a.txt', mimeType: 'text/plain' }); + await assert.rejects(withModule.encryptFile(undefined as never), /File encryption error\. Source file is missing\./); + + const noModule = world.client('open', 'crypto-none'); + await assert.rejects(noModule.encryptFile(file), /File encryption error\. Crypto module not configured\./); + // The page says decryptFile resolves with undefined when the client has no cryptoModule. + assert.equal(await noModule.decryptFile(file), undefined); + }); +}); diff --git a/docs-snippets/api-reference/file-sharing-legacy.ts b/docs-snippets/api-reference/file-sharing-legacy.ts new file mode 100644 index 000000000..cb7c7d22c --- /dev/null +++ b/docs-snippets/api-reference/file-sharing-legacy.ts @@ -0,0 +1,72 @@ +import assert from '../harness/assert.js'; +import fs from 'node:fs'; +import { page, step } from '../harness/step.js'; +import { enterWorkdir, FIXTURE_FILE } from '../fixtures/workdir.js'; + +/** + * docs/sdks/javascript/api-reference/file-sharing-legacy.md (deprecated `cipherKey` parameter) + * + * Sends the fixture with a per-call cipher key and downloads it again. The round trip proves the key is applied: + * the same file downloaded without the key is not the plain fixture. `sendFileCustomCipherKey` differs from the old + * docs body on purpose: its `import fs from 'fs'` line cannot sit inside a function, and the `importFS` snippet on + * the file sharing page already shows that import. + * + * Keyset: open (Files must be on, see README keyset setup). Phase: P8 (deprecated parameter). Sneaker Drop story: a + * shopper sends a product photo with a cipher key and a friend's client downloads it. Proves: the per-call + * `cipherKey` encrypts on send and decrypts on download. Deliberately does not prove: the normal file flow + * (file-sharing.ts) or the crypto module path (encryption.ts). + */ +export default page('api-reference/file-sharing-legacy', 'P8', (world) => { + // Plain bytes of the fixture, the reference for the round trip comparison. + const original = fs.readFileSync(FIXTURE_FILE); + // Hands the file id from the send step to the download step (steps share state only through the page closure). + let uploaded: { id: string } | undefined; + + // The snippet reads './cat_picture.jpg', so the step enters a temp workdir that holds a copy of the fixture and always + // restores the old directory in `finally`. 60 s timeout covers upload plus the Files service latency. + step('sendFileCustomCipherKey', { timeout: 60_000, proves: 'sendFile with a custom cipher key uploads the picture from a working directory.' }, async () => { + const pubnub = world.shopper; + const workdir = enterWorkdir(); + try { + // snippet.sendFileCustomCipherKey + // in Node.js + + const myFile = fs.readFileSync('./cat_picture.jpg'); + + const response = await pubnub.sendFile({ + channel: 'my_channel', + message: 'Look at this picture!', + file: { data: myFile, name: 'cat_picture.jpg', mimeType: 'image/jpeg' }, + cipherKey: 'myCipherKey', + }); + console.log('File sent successfully:', response); + // snippet.end + assert.ok(response.id); + uploaded = { id: response.id }; + } finally { + workdir.restore(); + } + }); + + // `needs` the send step. The snippet's `id: '...'` is a placeholder, so setPh supplies the live file id before the + // staged rewrite turns the literal into world.ph('fileId'). After the snippet it proves the key matters, then + // deletes the file (the janitor would also catch it). + step('downloadFileCustomCipherKey', { needs: ['sendFileCustomCipherKey'], timeout: 60_000, proves: 'downloadFile with the right custom cipher key returns the file and the wrong key does not.' }, async () => { + const pubnub = world.shopper; + assert.ok(uploaded, 'sendFileCustomCipherKey has not run'); + world.setPh('fileId', uploaded.id); + // snippet.downloadFileCustomCipherKey + const file = await pubnub.downloadFile({ + channel: 'my_channel', + id: '...', + name: 'cat_picture.jpg', + cipherKey: 'myCipherKey', + }); + // snippet.end + assert.deepEqual(await file.toBuffer(), original, 'the cipher key decrypts the upload'); + // Without the key the content is the encrypted payload. + const raw = await pubnub.downloadFile({ channel: world.n('my_channel'), id: uploaded.id, name: 'cat_picture.jpg' }); + assert.notDeepEqual(await raw.toBuffer(), original, 'no key, no plain file'); + await pubnub.deleteFile({ channel: world.n('my_channel'), id: uploaded.id, name: 'cat_picture.jpg' }); + }); +}); diff --git a/docs-snippets/api-reference/file-sharing.ts b/docs-snippets/api-reference/file-sharing.ts new file mode 100644 index 000000000..f1db172cb --- /dev/null +++ b/docs-snippets/api-reference/file-sharing.ts @@ -0,0 +1,270 @@ +import assert from '../harness/assert.js'; +import { Readable } from 'node:stream'; +import { page, step, check, stub } from '../harness/step.js'; +import { eventually, watch } from '../harness/wait.js'; +import { explainError } from '../harness/guard.js'; +import { enterWorkdir, FIXTURE_FILE } from '../fixtures/workdir.js'; + +// The docs show this import as snippet importFS, which lives in shared/import-pubnub.ts next to importPubNub. +import fs from 'fs'; + +/** + * docs/sdks/javascript/api-reference/file-sharing.md + * + * Node inputs only. The browser sample (`downloadFileWebBasicUsage`) is a documented stub for the browser phase, + * and the React and React Native sample is skipped because Node cannot run its `toBlob()`. + * + * The samples show `id: '...'`. The step that uploads the file supplies the real id through the `fileId` + * placeholder, and every later step sets the placeholder again before its snippet, because the legacy page + * (P8) uploads files too. Samples read `./cat_picture.jpg`, so each step runs in a temp directory that holds a + * copy of the fixture (`fixtures/workdir.ts`). + * + * Keyset: open (needs Files and Message Persistence on, checked by `preflightFiles`). Phase: P4, with the preflight and + * the final delete moved to P0 and P9. Sneaker Drop story: a shopper uploads a product photo to the drop channel, + * a second client sees the file event, and the file is listed, linked, downloaded, announced and removed. + * Proves: the Node file flow end to end, with a second client observing the real file event. Deliberately does not + * prove: browser and React Native behavior (stubs and skips below), or encryption (encryption.ts and + * file-sharing-legacy.ts). The janitor deletes leftovers if a run dies before `deleteFileBasicUsage`. + */ +export default page('api-reference/file-sharing', 'P4', (world) => { + // Plain bytes of the fixture, the reference every download is compared against. + const original = fs.readFileSync(FIXTURE_FILE); + // Filled by sendFileBasicUsage, read by every later step. The `needs` options guarantee it is set. + let uploaded: { id: string; name: string } | undefined; + // Returns the uploaded file and re-publishes its id as the `fileId` placeholder. Re-setting it each time matters + // because the legacy page (P8) uploads another file and overwrites the same placeholder. + const uploadedFile = (): { id: string; name: string } => { + assert.ok(uploaded, 'sendFileBasicUsage has not run'); + world.setPh('fileId', uploaded.id); + return uploaded; + }; + + // Lives here and not in harness/preflight.ts because it needs the file helpers. Sends and deletes a tiny file so a + // keyset without Files fails once with a named cause, and dependents skip as blocked-by:preflightFiles. + check('preflightFiles', { phase: 'P0', timeout: 60_000, proves: 'Preflight: the open keyset has Files enabled, checked by uploading and deleting a tiny file.' }, async () => { + const channel = world.n('preflight_channel'); + try { + const sent = await world.friend.sendFile({ channel, file: { data: Buffer.from('preflight'), name: 'preflight.txt', mimeType: 'text/plain' } }); + await world.friend.deleteFile({ channel, id: sent.id, name: 'preflight.txt' }); + } catch (error) { + explainError(error); + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`The open keyset cannot send files. Enable "File Sharing" (and Message Persistence) for it. ${detail}`); + } + }); + + // Registers a second client BEFORE the upload (`watch` waits until it is connected), so the file event cannot be missed. + // The temp workdir exists because the snippet reads './cat_picture.jpg'. After the snippet it records the file id for + // later steps and checks the event another client received. + step('sendFileBasicUsage', { needs: ['preflightFiles'], timeout: 60_000, proves: 'sendFile uploads the picture and a second client receives the file event.' }, async () => { + const pubnub = world.shopper; + const listening = await watch(world.fresh('fileobserver'), [world.n('my_channel')]); + const workdir = enterWorkdir(); + try { + // snippet.sendFileBasicUsage + // Function to send a file to a channel + // in node.js, make sure to import 'fs' + // and use the createReadStream method to read the file + const myFile = fs.createReadStream('./cat_picture.jpg'); + + const response = await pubnub.sendFile({ + channel: 'my_channel', + file: { stream: myFile, name: 'cat_picture.jpg', mimeType: 'image/jpeg' }, + customMessageType: 'file-message', + }); + + console.log('File sent successfully:', response); + // snippet.end + assert.ok(response.id, 'the response carries the file id'); + assert.equal(response.name, 'cat_picture.jpg'); + uploaded = { id: response.id, name: response.name }; + const event = await listening.next('file', (e) => e.file?.id === response.id); + assert.equal(event.channel, world.n('my_channel')); + assert.equal(event.file?.name, 'cat_picture.jpg'); + assert.equal(event.publisher, pubnub.getUserId()); + } finally { + listening.stop(); + workdir.restore(); + } + }); + + // Reads the file the send step uploaded, which is why it `needs` it. No wait: the send step already saw the file event. + step('listFilesBasicUsage', { needs: ['sendFileBasicUsage'], proves: 'listFiles returns the file the send step uploaded.' }, async () => { + const pubnub = world.shopper; + const { id } = uploadedFile(); + // snippet.listFilesBasicUsage + const response = await pubnub.listFiles({ channel: 'my_channel' }); + console.log('Files listed successfully:', response); + // snippet.end + const entry = response.data.find((f) => f.id === id); + assert.ok(entry, 'the uploaded file is listed'); + assert.equal(entry.name, 'cat_picture.jpg'); + }); + + // Pure URL building, then a real GET with the real file name to prove the URL serves the bytes. + step('getFileUrlBasicUsage', { needs: ['sendFileBasicUsage'], proves: 'getFileUrl builds a URL that serves the uploaded bytes.' }, async () => { + const pubnub = world.shopper; + const { id, name } = uploadedFile(); + // snippet.getFileUrlBasicUsage + const response = pubnub.getFileUrl({ channel: 'my_channel', id: '...', name: 'cat_picture.jpg' }); + // snippet.end + assert.match(response, /^https:\/\//); + assert.ok(response.includes(`/files/${id}/`), 'the URL carries the file id'); + assert.ok(response.includes(`/${name}?`), 'the URL carries the file name'); + assert.ok(response.includes(encodeURIComponent(world.n('my_channel'))), 'the URL carries the channel'); + // The URL with the real file name serves the uploaded bytes. + const real = pubnub.getFileUrl({ channel: world.n('my_channel'), id, name }); + const served = await fetch(real); + assert.equal(served.status, 200); + assert.deepEqual(Buffer.from(await served.arrayBuffer()), original); + }); + + // The fixture file is pre-created so the snippet's write stream overwrites something observable. The sample waits for + // 'finish' on the write stream ('end' never fires on a write stream, docs row 1), so the step also proves the log line. + step('downloadFileNodeBasicUsage', { needs: ['sendFileBasicUsage'], timeout: 60_000, proves: 'downloadFile in Node writes the downloaded content to the output file.' }, async () => { + const pubnub = world.shopper; + uploadedFile(); + const workdir = enterWorkdir(); + const logs = world.captureConsole('log'); + try { + fs.writeFileSync('./cat_picture.jpg', 'placeholder to be overwritten'); + // snippet.downloadFileNodeBasicUsage + // In Node.js using streams: + // import fs from 'fs' + const downloadFileResponse = await pubnub.downloadFile({ + channel: 'my_channel', + id: '...', + name: 'cat_picture.jpg', + }); + + const output = fs.createWriteStream('./cat_picture.jpg'); + const fileStream = await downloadFileResponse.toStream(); + + fileStream.pipe(output); + + output.once('finish', () => { + console.log('File saved to ./cat_picture.jpg'); + }); + // snippet.end + await eventually(() => { + assert.deepEqual(fs.readFileSync('./cat_picture.jpg'), original); + }, { label: 'downloaded file written' }); + await eventually(() => { + assert.ok(logs.lines.includes('File saved to ./cat_picture.jpg'), 'the finish handler logged the confirmation'); + }, { label: 'finish event logged' }); + assert.equal(downloadFileResponse.name, 'cat_picture.jpg'); + } finally { + logs.restore(); + workdir.restore(); + } + }); + + // The React Native and browser download samples (downloadFileReactNativeBasicUsage, downloadFileWebBasicUsage) need + // React Native or a browser. Their sources are quickstarts/react-native-download-file.ts and + // quickstarts/browser-download-file.ts. This repository cannot run them (no React Native, no browser). + + // Prose-only parts of the page that no browser-free run can execute. They appear in the report as skipped stubs. + stub( + 'fileInputBrowserForms', + 'browser-only', + 'FileInput forms for browsers (File API, string, ArrayBuffer or Blob) and the PubNubFile methods toFile and toBlob need a browser. Hardcoded in the docs, no snippet id.', + ); + + stub( + 'fileInputReactNativeUri', + 'react-native', + 'FileInput with a uri and the React Native PubNubFile methods need React Native. Hardcoded in the docs, no snippet id.', + ); + + // Publishes a file message for the file uploaded earlier. The observer is registered first (race avoidance) and the + // event is matched by timetoken, so another file event on the channel cannot satisfy it. + step('publishFileMessageBasicUsage', { needs: ['sendFileBasicUsage'], proves: 'publishFileMessage announces the uploaded file and the event is matched by timetoken.' }, async () => { + const pubnub = world.shopper; + const { id } = uploadedFile(); + const listening = await watch(world.fresh('fileobserver'), [world.n('my_channel')]); + // snippet.publishFileMessageBasicUsage + const fileMessageResponse = await pubnub.publishFile({ + channel: 'my_channel', + fileId: '...', + fileName: 'cat_picture.jpg', + message: { field: 'value' }, + customMessageType: 'file-message', + }); + console.log('File message published successfully:', fileMessageResponse); + // snippet.end + world.check.timetoken(fileMessageResponse.timetoken); + const event = await listening.next('file', (e) => e.timetoken === String(fileMessageResponse.timetoken)); + assert.equal(event.file?.id, id); + assert.deepEqual(event.message, { field: 'value' }); + listening.stop(); + }); + + // ---- FileInput forms and PubNubFile in Node --------------------------------------------------------------------- + + // Round trips every Node input form the page lists. Each upload is deleted in `finally`, so a failed assertion + // still cleans up. + check('fileInputNodeForms', { needs: ['preflightFiles'], timeout: 90_000, proves: 'Every Node file input form listed on the page uploads and downloads correctly.' }, async () => { + // The docs list stream, buffer, ArrayBuffer and string forms for Node.js. Each one must upload and come back unchanged. + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const text = 'sneaker drop receipt'; + const forms: Array<{ label: string; file: Parameters[0]['file']; expected: Buffer }> = [ + { label: 'stream', file: { stream: Readable.from([original]), name: 'stream.jpg', mimeType: 'image/jpeg' }, expected: original }, + { label: 'buffer', file: { data: original, name: 'buffer.jpg', mimeType: 'image/jpeg' }, expected: original }, + { label: 'ArrayBuffer', file: { data: new Uint8Array(original).buffer, name: 'arraybuffer.jpg', mimeType: 'image/jpeg' }, expected: original }, + { label: 'string', file: { data: text, name: 'string.txt', mimeType: 'text/plain' }, expected: Buffer.from(text) }, + ]; + for (const form of forms) { + const sent = await pubnub.sendFile({ channel, file: form.file }); + try { + const name = (form.file as { name: string }).name; + const downloaded = await pubnub.downloadFile({ channel, id: sent.id, name }); + assert.deepEqual(await downloaded.toBuffer(), form.expected, `${form.label} round trip`); + } finally { + await pubnub.deleteFile({ channel, id: sent.id, name: (form.file as { name: string }).name }); + } + } + }); + + // Records which PubNubFile conversions work in Node and which reject, as the page's table says. + check('pubnubFileNodeMethods', { needs: ['sendFileBasicUsage'], timeout: 60_000, proves: 'The PubNubFile conversions that work in Node work and the others reject as the page table says.' }, async () => { + // Docs, "Methods supported in Node.js": toBuffer, toStream, toString, toArrayBuffer work. toFile, toBlob and toFileUri reject. + const pubnub = world.shopper; + const { id, name } = uploadedFile(); + const channel = world.n('my_channel'); + const read = async () => pubnub.downloadFile({ channel, id, name }); + const stream = await (await read()).toStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + assert.deepEqual(Buffer.concat(chunks), original, 'toStream'); + assert.deepEqual(await (await read()).toBuffer(), original, 'toBuffer'); + assert.deepEqual(Buffer.from(await (await read()).toArrayBuffer()), original, 'toArrayBuffer'); + assert.equal((await (await read()).toString('base64')), original.toString('base64'), 'toString with an encoding'); + for (const method of ['toFile', 'toBlob', 'toFileUri'] as const) { + const target = await read(); + await assert.rejects(() => (target as unknown as Record Promise>)[method](), /only supported in/, `${method} rejects in Node`); + } + }); + + // ---- teardown --------------------------------------------------------------------------------------------------- + + // Runs in P9 and `needs` every step that still uses the file, so deletion cannot starve them. The final poll covers + // the delay between the delete and the list update. + step('deleteFileBasicUsage', { phase: 'P9', needs: ['publishFileMessageBasicUsage', 'downloadFileNodeBasicUsage', 'getFileUrlBasicUsage', 'listFilesBasicUsage'], proves: 'deleteFile removes the file and a later listing no longer shows it.' }, async () => { + const pubnub = world.shopper; + uploadedFile(); + // snippet.deleteFileBasicUsage + const deleteFileResponse = await pubnub.deleteFile({ + channel: 'my_channel', + id: '...', + name: 'cat_picture.jpg', + }); + console.log('File deleted successfully:', deleteFileResponse); + // snippet.end + assert.equal(deleteFileResponse.status, 200); + await eventually(async () => { + const left = await world.observer.listFiles({ channel: world.n('my_channel') }); + assert.ok(!left.data.some((f) => f.id === uploaded!.id), 'the file is no longer listed'); + }, { label: 'file removed from the list' }); + }); +}); diff --git a/docs-snippets/api-reference/message-actions.ts b/docs-snippets/api-reference/message-actions.ts new file mode 100644 index 000000000..a6accff74 --- /dev/null +++ b/docs-snippets/api-reference/message-actions.ts @@ -0,0 +1,109 @@ +import assert from '../harness/assert.js'; +import { page, step } from '../harness/step.js'; +import { eventually, watch } from '../harness/wait.js'; +import { addToTimetoken } from '../harness/assertions.js'; + +/** + * docs/sdks/javascript/api-reference/message-actions.md + * + * Keyset: open (needs Message Persistence, hence the `preflightPersistence` dependency). Phase: P4. In the Sneaker Drop + * story a friend posts a message in a drop channel and the shopper reacts to it. + * + * The docs snippets use placeholder timetokens (`replace_with_message_timetoken` and friends). The staged copy turns + * them into `world.ph(...)` calls, and each step sets the live values with `world.setPh` before the snippet runs. + * What it proves: add, remove and get of message actions, including the `messageAction` event a second subscriber + * sees. What it does not prove: pagination of getMessageActions beyond one action. + */ +export default page('api-reference/message-actions', 'P4', (world) => { + // Proves addMessageAction returns the action and that a subscriber gets an `added` messageAction event. + // The observer subscribes BEFORE the action is added (watch records events, so the later `next` cannot race it). + // The action timetoken is stored as a placeholder for the remove step. + step('addMessageActionBasicUsage', { needs: ['preflightPersistence'], proves: 'addMessageAction returns the action and a subscriber receives an added event.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('channel_name'); + const listening = await watch(world.fresh('observer'), [channel]); + const published = await world.friend.publish({ channel, message: { text: 'react to me' } }); + world.setPh('messageTimetoken', published.timetoken); + + // snippet.addMessageActionBasicUsage + // first publish a message using publish() method to get the message timetoken + const response = await pubnub.addMessageAction({ + channel: 'channel_name', + messageTimetoken: 'replace_with_message_timetoken', // Replace with actual message timetoken + action: { + type: 'reaction', + value: 'smiley_face', + }, + }); + console.log('Message reaction added successfully:', response); + + // snippet.end + world.setPh('actionTimetoken', response.data.actionTimetoken); + assert.equal(response.data.type, 'reaction'); + assert.equal(response.data.value, 'smiley_face'); + assert.equal(response.data.messageTimetoken, published.timetoken); + const event = await listening.next('messageAction', (e) => e.data.actionTimetoken === response.data.actionTimetoken); + assert.equal(event.event, 'added'); + assert.equal(event.data.uuid, pubnub.getUserId()); + listening.stop(); + }); + + // Proves removeMessageAction emits a `removed` event and that the action disappears from the stored list. + // Reads the message and action timetokens placeholders set by the add step, hence the `needs`. + // The list read polls: storage lags the event slightly. + step('removeMessageActionBasicUsage', { needs: ['addMessageActionBasicUsage', 'preflightPersistence'], proves: 'removeMessageAction emits a removed event and the action disappears from the stored list.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('channel_name'); + const listening = await watch(world.fresh('observer'), [channel]); + const actionTimetoken = world.ph('actionTimetoken'); + + // snippet.removeMessageActionBasicUsage + const response = await pubnub.removeMessageAction({ + channel: 'channel_name', + messageTimetoken: 'replace_with_message_timetoken', + actionTimetoken: 'replace_with_action_timetoken', + }); + console.log('Message action removed successfully:', response); + // snippet.end + const event = await listening.next('messageAction', (e) => e.data.actionTimetoken === actionTimetoken); + assert.equal(event.event, 'removed'); + await eventually(async () => { + const left = await world.observer.getMessageActions({ channel }); + assert.ok(!left.data.some((a) => a.actionTimetoken === actionTimetoken), 'action is gone'); + }, { label: 'removed action is no longer listed' }); + listening.stop(); + }); + + // Proves getMessageActions returns the one action added during setup, with the right type, value and message timetoken. + // start and end are placeholders around that single action, so the result is exactly one entry. + step('getMessageActionsBasicUsage', { needs: ['preflightPersistence'], proves: 'getMessageActions returns the one action added during setup with the right type, value and message timetoken.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('channel_name'); + // Publish a message and add one action to it, as the snippet comment asks. + const published = await world.friend.publish({ channel, message: { text: 'count my reactions' } }); + const added = await world.friend.addMessageAction({ + channel, + messageTimetoken: published.timetoken, + action: { type: 'reaction', value: 'thumbs_up' }, + }); + // Actions are listed newest first: `start` is the exclusive upper bound, `end` the inclusive lower bound. + world.setPh('actionsStart', addToTimetoken(added.data.actionTimetoken, 1)); + world.setPh('actionsEnd', added.data.actionTimetoken); + + // snippet.getMessageActionsBasicUsage + // to get some data in response, first publish a message and then add a message action + // using addMessageAction() method. + const response = await pubnub.getMessageActions({ + channel: 'channel_name', + start: 'replace_with_start_timetoken', + end: 'replace_with_end_timetoken', + limit: 100, + }); + console.log('Message actions retrieved successfully:', response); + // snippet.end + assert.equal(response.data.length, 1); + assert.equal(response.data[0].actionTimetoken, added.data.actionTimetoken); + assert.equal(response.data[0].value, 'thumbs_up'); + assert.equal(response.data[0].messageTimetoken, published.timetoken); + }); +}); diff --git a/docs-snippets/api-reference/message-persistence-legacy.ts b/docs-snippets/api-reference/message-persistence-legacy.ts new file mode 100644 index 000000000..c72cd4899 --- /dev/null +++ b/docs-snippets/api-reference/message-persistence-legacy.ts @@ -0,0 +1,412 @@ +import type PubNub from 'pubnub'; +import assert from '../harness/assert.js'; +import { page, step, check } from '../harness/step.js'; +import { eventually, sleep } from '../harness/wait.js'; +import { recorder } from '../harness/recorder.js'; +import { addToTimetoken } from '../harness/assertions.js'; + +/** + * docs/sdks/javascript/api-reference/message-persistence-legacy.md + * + * Keyset: open. Phase: P8 "Legacy". In the Sneaker Drop story this is the old way the shop app read a drop's history. + * `history()` is the deprecated reader of Message Persistence, and the page also documents the deprecated `data` + * property of `actions` on `fetchMessages()`. The docs page hardcodes every sample, so the snippet ids below are + * proposed ids, the docs page does not embed them yet. + * + * Every step seeds its own messages with the `friend` client and waits until they are stored before it runs the + * snippet once (storage is eventually consistent). The two `check`s at the end have no snippet: they prove the prose + * and Returns block of the page. The history anchors in the docs are 1970 literals, so each step maps them to live + * timetokens through placeholders (`world.setPh`). It does not prove retention expiry. + */ +export default page('api-reference/message-persistence-legacy', 'P8', (world) => { + // Publishes `count` messages as the friend, optionally with `meta`, and returns their timetokens, oldest first. + const seed = async (channel: string, count: number, extra: { meta?: Record } = {}): Promise => { + const tokens: string[] = []; + for (let i = 0; i < count; i += 1) { + const { timetoken } = await world.friend.publish({ channel, message: { legacy: 'history', n: i }, ...extra }); + tokens.push(timetoken); + } + return tokens; + }; + + // Fine for up to 100 messages. For larger sets use untilAllStored below. + const untilStored = (channel: string, tokens: string[]): Promise => + eventually(async () => { + const history = await world.observer.fetchMessages({ channels: [channel], count: 100 }); + const have = new Set((history.channels[channel] ?? []).map((m) => String(m.timetoken))); + for (const token of tokens) assert.ok(have.has(token), `${token} is not stored yet`); + }, { timeout: 30_000, label: `messages stored on ${channel}` }); + + /** + * Waits until every one of `tokens` is stored, however many there are. `untilStored` reads one page of at most 100 + * messages (the cap of `fetchMessages`), so with more than 100 seeded messages it can only vouch for the newest + * 100. This one walks the channel page by page from the newest message to the oldest, always asking for the page + * that ends before the oldest message of the previous page, and checks the whole set of tokens at the end. + * Storage is eventually consistent, so the walk repeats until nothing is missing or the timeout passes. + */ + const untilAllStored = (channel: string, tokens: string[]): Promise => + eventually(async () => { + const have = new Set(); + let before: string | undefined; + for (;;) { + const page = await world.observer.fetchMessages({ channels: [channel], count: 100, ...(before ? { start: before } : {}) }); + const items = page.channels[channel] ?? []; + for (const item of items) have.add(String(item.timetoken)); + // A page shorter than the cap is the last one. Messages come oldest first, so items[0] is the oldest of the page. + if (items.length < 100) break; + before = String(items[0].timetoken); + } + const missing = tokens.filter((token) => !have.has(token)); + assert.equal(missing.length, 0, `${missing.length} of ${tokens.length} seeded messages are not stored yet (first missing: ${missing[0]})`); + }, { timeout: 45_000, label: `all ${tokens.length} messages stored on ${channel}` }); + + /** Every stored message of a channel, oldest first, through the non-deprecated reader. */ + // Reference reader for expected values: the non-deprecated fetchMessages, so history() is compared against an independent source. + const stored = async (channel: string): Promise => { + const history = await world.observer.fetchMessages({ channels: [channel], count: 100 }); + return (history.channels[channel] ?? []).map((m) => String(m.timetoken)); + }; + + // Normalizes timetokens to strings, history() returns numbers unless stringifiedTimeToken is set. + const tokensOf = (result: { messages: Array<{ timetoken: string | number }> }): string[] => result.messages.map((m) => String(m.timetoken)); + + // history() always returns oldest first, whatever `reverse` is. + const isAscending = (tokens: string[]): boolean => tokens.every((t, i) => i === 0 || BigInt(tokens[i - 1]) < BigInt(t)); + + // Proves history() returns the three seeded messages oldest first, with start and end timetokens of the first and last item. + step('historyBasicUsage', { needs: ['preflightPersistence'], proves: 'history returns the three seeded messages oldest first with the start and end timetokens.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('history_channel'); + const tokens = await seed(channel, 3); + await untilStored(channel, tokens); + + // snippet.historyBasicUsage + try { + const result = await pubnub.history({ + channel: "history_channel", + count: 100, // how many items to fetch + stringifiedTimeToken: true, // false is the default + }); + // snippet.hide + world.keep('result', result); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const result = world.take('result'); + + assert.deepEqual(tokensOf(result), tokens, 'the three seeded messages, oldest first'); + assert.deepEqual((result.messages[0].entry as { n: number }).n, 0); + assert.equal(result.startTimeToken, tokens[0]); + assert.equal(result.endTimeToken, tokens[2]); + }); + + // Proves reverse: true with count 3 returns the three OLDEST messages of the channel (expected values come from fetchMessages). + step('historyOldestThree', { needs: ['preflightPersistence'], proves: 'history with reverse true and count 3 returns the three oldest messages.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const tokens = await seed(channel, 3); + await untilStored(channel, tokens); + const expected = (await stored(channel)).slice(0, 3); + assert.equal(expected.length, 3); + + // snippet.historyOldestThree + try { + const result = await pubnub.history({ + channel: "my_channel", + reverse: true, // Setting to true will traverse the time line in reverse starting with the oldest message first. + count: 3, // how many items to fetch + stringifiedTimeToken: true, // false is the default + }); + // snippet.hide + world.keep('result', result); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const result = world.take('result'); + + assert.deepEqual(tokensOf(result), expected, 'the three oldest messages of the channel'); + }); + + // Proves reverse + start returns messages newer than the anchor. The anchor is a placeholder set to the middle message, start is exclusive. + step('historyNewerThanTimetoken', { needs: ['preflightPersistence'], proves: 'history with reverse and start returns only messages newer than the anchor, and start is exclusive.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const [, middle, newest] = await seed(channel, 3); + await untilStored(channel, [newest]); + world.setPh('historyAnchor', middle); + + // snippet.historyNewerThanTimetoken + try { + const result = await pubnub.history({ + channel: "my_channel", + reverse: true, // Setting to true will traverse the time line in reverse starting with the oldest message first. + stringifiedTimeToken: true, // false is the default + start: "13406746780720711", // start timetoken to fetch + }); + // snippet.hide + world.keep('result', result); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const result = world.take('result'); + + const tokens = tokensOf(result); + assert.equal(tokens[0], newest, 'start is exclusive: the first message is the one after the anchor'); + assert.ok(!tokens.includes(middle), 'the anchor itself is not returned'); + assert.ok(tokens.every((t) => BigInt(t) > BigInt(middle))); + assert.ok(isAscending(tokens)); + }); + + // Proves `end` alone returns messages down to the anchor, and end is inclusive (the anchor is the oldest returned). + step('historyUntilTimetoken', { needs: ['preflightPersistence'], proves: 'history with only end returns messages down to the anchor, and end is inclusive.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const [oldest, middle, newest] = await seed(channel, 3); + await untilStored(channel, [newest]); + world.setPh('historyAnchor', middle); + + // snippet.historyUntilTimetoken + try { + const result = await pubnub.history({ + channel: "my_channel", + stringifiedTimeToken: true, // false is the default + end: "13406746780720711", // end timetoken to fetch + }); + // snippet.hide + world.keep('result', result); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const result = world.take('result'); + + const tokens = tokensOf(result); + assert.equal(tokens[0], middle, 'end is inclusive: the anchor is the oldest returned message'); + assert.ok(tokens.includes(newest)); + assert.ok(!tokens.includes(oldest), 'older than the anchor is not returned'); + assert.ok(isAscending(tokens)); + }); + + // Proves the docs' own paging helper collects every message. 120 messages means two pages, so the loop must page. + // The helper is polled with `eventually` because the oldest messages can lag behind the newest 100. + // + // The SDK does not retry history, and one lost DNS lookup (`getaddrinfo ENOTFOUND`) on a developer machine failed a whole run. + // So this step uses its own client whose `history` retries ONLY a PNNetworkIssuesCategory failure, at most 3 attempts with a + // growing pause. Any other failure (access denied, validation, a wrong result) is thrown at once, and a third network failure + // is thrown with the retry count in the message. The retries are listed in the report. The snippet text is unchanged, + // it calls `pubnub.history` and gets the wrapped method. + step('historyPagingExample', { needs: ['preflightPersistence'], timeout: 120_000, proves: 'The paging helper of the page collects all 120 messages across two pages.' }, async () => { + const pubnub = world.fresh('history-paging'); + const originalHistory = pubnub.history.bind(pubnub) as (...args: unknown[]) => Promise; + (pubnub as unknown as { history: unknown }).history = async (...args: unknown[]): Promise => { + const attempts = 3; + for (let attempt = 1; ; attempt += 1) { + try { + return await originalHistory(...args); + } catch (error) { + const category = (error as { status?: { category?: string } }).status?.category; + if (category !== 'PNNetworkIssuesCategory') throw error; + if (attempt >= attempts) { + (error as Error).message = `${(error as Error).message} (history failed ${attempts} times with a network error)`; + throw error; + } + recorder.record({ kind: 'retry', label: `history retried after a network error (attempt ${attempt} of ${attempts})`, ok: true }); + await sleep(1_000 * attempt); + } + } + }; + const channel = world.n('history_test'); + // More than one page of 100 messages, so the loop has to page. + const tokens: string[] = []; + for (let batch = 0; batch < 6; batch += 1) { + const sent = await Promise.all( + Array.from({ length: 20 }, (_, i) => world.friend.publish({ channel, message: { legacy: 'paging', n: batch * 20 + i } })), + ); + tokens.push(...sent.map((s) => s.timetoken)); + } + // Wait for all 120 seeded messages, not only the newest 100. The oldest 20 sit on the second page. + await untilAllStored(channel, tokens); + await eventually(async () => { + const probe = await world.observer.fetchMessages({ channels: [channel], count: 100 }); + assert.equal(probe.channels[channel]?.length, 100); + }, { timeout: 30_000, label: 'first page is full' }); + + // snippet.historyPagingExample + async function getAllMessages(initialTimetoken = '') { + const allMessages = []; + let latestCount = 100; + let timetoken = initialTimetoken; + + while (latestCount === 100) { + const { messages, startTimeToken, endTimeToken } = await pubnub.history( + { + channel: 'history_test', + stringifiedTimeToken: true, // false is the default + start: timetoken, // start timetoken to fetch + } + ); + + latestCount = messages.length; + timetoken = String(startTimeToken); + + if (messages && messages.length > 0) { + allMessages.push(...messages); + } + } + + return allMessages; + } + + // Usage examples: + // await getAllMessages(); + // await getAllMessages("12345678901234"); + // snippet.end + + // History is eventually consistent, so wait until the oldest messages are stored too. + const all = await eventually(async () => { + const found = await getAllMessages(); + assert.equal(found.length, 120, 'paging returns every message'); + return found; + }, { timeout: 45_000, label: 'paging collects all seeded messages' }); + assert.deepEqual(new Set(all.map((m) => m.timetoken)), new Set(tokens), 'each seeded message exactly once'); + // A valid timetoken starts the walk there: nothing newer than it is returned. + const sortedTokens = [...tokens].sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1)); + const fromMiddle = await getAllMessages(sortedTokens[59]); + assert.equal(fromMiddle.length, 59, 'start is exclusive, so only the 59 messages before the 60th are returned'); + }); + + // Proves includeMeta returns `meta` for a message published with metadata and omits the property for others. + step('historyIncludeMeta', { needs: ['preflightPersistence'], proves: 'includeMeta returns meta for a message published with metadata and omits it for others.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const [withMeta] = await seed(channel, 1, { meta: { legacy: 'meta' } }); + await untilStored(channel, [withMeta]); + + // snippet.historyIncludeMeta + try { + const result = await pubnub.history({ + channel: "my_channel", + stringifiedTimeToken: true, + includeMeta: true, + }); + // snippet.hide + world.keep('result', result); + // snippet.show + } catch (status) { + console.log(status); + } + // snippet.end + const result = world.take('result'); + + const found = result.messages.find((m) => m.timetoken === withMeta); + assert.ok(found, 'the seeded message is returned'); + assert.deepEqual(found.meta, { legacy: 'meta' }); + // The meta property is present only when the publisher set metadata. + assert.ok(result.messages.some((m) => m.meta === undefined), 'messages without metadata have no meta property'); + }); + + // Proves the promise form works. The snippet only logs, so the step captures console.log and parses the logged response. + // The docs literals follow the parameter table: start is the higher timetoken, end the lower one. + step('historyWithPromises', { needs: ['preflightPersistence'], proves: 'The promise form of history logs the response with the expected messages.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('history_channel'); + const tokens = await seed(channel, 3); + await untilStored(channel, tokens); + // The docs literals are 1970 timetokens. The live values are the newest boundary (start) and the oldest one (end). + world.setPh('historyPromiseStart', addToTimetoken(tokens[2], 1)); + world.setPh('historyPromiseEnd', tokens[0]); + + await world.withConsole(async (logs) => { + // snippet.historyWithPromises + pubnub.history({ + channel: 'history_channel', + reverse: true, // Setting to true will traverse the time line in reverse starting with the oldest message first. + count: 100, // how many items to fetch + stringifiedTimeToken: true, // false is the default + start: '123123123133', // start timetoken to fetch + end: '123123123123', // end timetoken to fetch + }).then((response) => { + console.log(response); + }).catch((error) => { + console.log(error); + }); + // snippet.end + + await eventually(() => { + assert.ok(logs.lines.some((line) => line.includes('"messages"')), `the response was logged. Logged: ${logs.lines.join(' | ').slice(0, 300)}`); + }, { label: 'promise resolved and logged' }); + const response = JSON.parse(logs.lines.find((line) => line.includes('"messages"'))!); + assert.deepEqual(response.messages.map((m: { timetoken: string }) => String(m.timetoken)), tokens); + }); + }); + + // ---- the prose and the Returns block of the page ------------------------------------------------------------- + + // Proves the prose claims of the page (cap of 100, ordering, Returns fields, number vs string timetokens, start/end order). Needs the paging step's channel of 120 messages. + check('historyDocumentedBehavior', { needs: ['historyPagingExample'], timeout: 60_000, proves: 'The prose claims of the history page hold: the cap of 100, ordering, returned fields and timetoken types.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('history_test'); + const probe = (parameters: Partial[0]>) => + pubnub.history({ channel, stringifiedTimeToken: true, ...parameters }); + + // "The maximum is 100, and history() lowers a larger value to 100." + assert.equal((await probe({ count: 150 })).messages.length, 100); + + // "history() always returns the messages sorted from the oldest to the newest, whatever reverse is." + const newestFive = await probe({ count: 5 }); + const oldestFive = await probe({ count: 5, reverse: true }); + for (const result of [newestFive, oldestFive]) assert.ok(isAscending(tokensOf(result))); + // reverse decides which end of the interval the retrieval starts from. + assert.ok(BigInt(oldestFive.messages[4].timetoken) < BigInt(newestFive.messages[0].timetoken), 'reverse starts from the oldest end'); + + // Returns: startTimeToken and endTimeToken hold the timetokens of the first and last returned message, as strings. + assert.equal(newestFive.startTimeToken, newestFive.messages[0].timetoken); + assert.equal(newestFive.endTimeToken, newestFive.messages[4].timetoken); + assert.equal(typeof newestFive.startTimeToken, 'string'); + for (const message of newestFive.messages) { + assert.deepEqual(Object.keys(message).sort(), ['entry', 'timetoken'], 'an item has entry and timetoken, and meta only when set'); + } + + // Without stringifiedTimeToken the timetokens are numbers. + const plain = await pubnub.history({ channel, count: 1 }); + assert.equal(typeof plain.messages[0].timetoken, 'number'); + + // "With start, start must be a higher timetoken than end." The docs promises sample passes start below end. Record what the service does with that order. + const older = String(oldestFive.messages[0].timetoken); + const newer = String(newestFive.messages[4].timetoken); + const inverted = await probe({ count: 100, start: older, end: newer }); + const ordered = await probe({ count: 100, start: newer, end: older }); + console.log(`history with start below end returned ${inverted.messages.length} messages, with start above end ${ordered.messages.length}`); + assert.ok(ordered.messages.length > 0, 'start above end returns the messages in between'); + }); + + // Proves the deprecated `data` property on actions equals `actions`, and that both are absent without includeMessageActions. + check('historyActionsDataDeprecated', { needs: ['preflightPersistence'], proves: 'The deprecated data property of actions equals actions, and both are absent without includeMessageActions.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('persist_channel'); + const [token] = await seed(channel, 1); + await world.friend.addMessageAction({ channel, messageTimetoken: token, action: { type: 'reaction', value: 'smiley_face' } }); + type WithActions = { timetoken: string | number; actions?: Record>; data?: unknown }; + const found = await eventually(async () => { + const response = await pubnub.fetchMessages({ channels: [channel], includeMessageActions: true }); + const item = response.channels[channel]?.find((m) => String(m.timetoken) === token) as WithActions | undefined; + assert.ok(item?.actions, 'the message has actions'); + return item; + }, { timeout: 30_000, label: 'message with action stored' }); + assert.deepEqual(found.data, found.actions, 'the deprecated data property holds the same value as actions'); + assert.ok(found.actions?.reaction?.smiley_face, 'grouped by action type, then by action value'); + + // "The response has them only with includeMessageActions and when the message has actions." + const without = await pubnub.fetchMessages({ channels: [channel] }); + const plain = without.channels[channel]?.find((m) => String(m.timetoken) === token) as WithActions | undefined; + assert.equal(plain?.actions, undefined); + assert.equal(plain?.data, undefined); + }); +}); diff --git a/docs-snippets/api-reference/message-persistence.ts b/docs-snippets/api-reference/message-persistence.ts new file mode 100644 index 000000000..4ccd01002 --- /dev/null +++ b/docs-snippets/api-reference/message-persistence.ts @@ -0,0 +1,198 @@ +import assert from '../harness/assert.js'; +import { page, step } from '../harness/step.js'; +import { eventually } from '../harness/wait.js'; +import { addToTimetoken } from '../harness/assertions.js'; + +/** + * docs/sdks/javascript/api-reference/message-persistence.md + * + * Keyset: open. Phase: P5. In the Sneaker Drop story this is the shopper catching up on a drop channel's history. + * + * Every step seeds its own messages with the `friend` client, so the steps do not depend on what the real-time steps + * published. History is eventually consistent: each step first waits until the seeded messages are stored, then runs + * the snippet once. `deleteMessages` needs "delete from history" on the keyset (`preflightDeleteFromHistory`). + * The snippets use placeholder timetokens, set with `world.setPh` after seeding. + * What it does not prove: retention expiry, and paging beyond one fetch (see message-persistence-legacy.ts for paging). + */ +export default page('api-reference/message-persistence', 'P5', (world) => { + // Publishes `count` messages as the friend and returns their timetokens, oldest first. + const seed = async (channel: string, count: number): Promise => { + const tokens: string[] = []; + for (let i = 0; i < count; i += 1) { + const { timetoken } = await world.friend.publish({ channel, message: { seed: i }, customMessageType: 'seed' }); + tokens.push(timetoken); + } + return tokens; + }; + + type WithActions = { actions?: Record> }; + + // Waits until every seeded timetoken is visible to a reader. The write and the read model are separate, so one read is not enough. + const untilStored = (channel: string, tokens: string[]): Promise => + eventually(async () => { + const history = await world.observer.fetchMessages({ channels: [channel], count: 100 }); + const have = new Set((history.channels[channel] ?? []).map((m) => String(m.timetoken))); + for (const token of tokens) assert.ok(have.has(token), `${token} is not stored yet`); + }, { timeout: 30_000, label: `messages stored on ${channel}` }); + + // Proves fetchMessages with count, start and end returns exactly one seeded message. + // start is exclusive and end inclusive, so the placeholders bracket the seeded messages (newest + 1 and oldest). + step('fetchMessagesBasicUsage', { needs: ['preflightPersistence'], proves: 'fetchMessages with count, start and end returns exactly one seeded message.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my-channel'); + const tokens = await seed(channel, 3); + await untilStored(channel, tokens); + // `start` is the exclusive newer bound and `end` the inclusive older bound. + world.setPh('historyStart', addToTimetoken(tokens[2], 1)); + world.setPh('historyEnd', tokens[0]); + + // snippet.fetchMessagesBasicUsage + // Function to fetch message history + const result = await pubnub.fetchMessages({ + channels: ['my-channel'], + count: 1, // Number of messages to retrieve + includeCustomMessageType: true, // if you want to include custom message type in the response + start: 'replace-with-start-timetoken', // start timetoken + end: 'replace-with-end-timetoken', // end timetoken + }); + console.log('Fetched Messages:', result); + // snippet.end + const messages = result.channels[channel] ?? []; + assert.equal(messages.length, 1, 'count: 1 returns one message'); + assert.ok(tokens.includes(String(messages[0].timetoken)), 'the message is one of the seeded ones'); + assert.equal(messages[0].customMessageType, 'seed'); + }); + + // Proves includeMeta, includeMessageActions and includeCustomMessageType come back on the message. + // The setup waits until the action is attached, because an action is stored separately from its message. + step('fetchMessagesWithMetaActions', { needs: ['preflightPersistence'], proves: 'fetchMessages returns meta, message actions and the custom message type when asked for.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const { timetoken } = await world.friend.publish({ + channel, + message: { text: 'with meta and an action' }, + meta: { source: 'docs-snippets' }, + customMessageType: 'text-message', + }); + await world.friend.addMessageAction({ + channel, + messageTimetoken: timetoken, + action: { type: 'reaction', value: 'smiley_face' }, + }); + await eventually(async () => { + const probe = await world.observer.fetchMessages({ channels: [channel], includeMessageActions: true, includeMeta: true }); + const found = probe.channels[channel]?.find((m) => String(m.timetoken) === timetoken); + assert.ok((found as WithActions | undefined)?.actions, 'message and its action are stored'); + }, { timeout: 30_000, label: 'message with action stored' }); + + // snippet.fetchMessagesWithMetaActions + const response = await pubnub.fetchMessages({ + channels: ['my_channel'], + stringifiedTimeToken: true, + includeMeta: true, + includeMessageActions: true, + includeCustomMessageType: true, + }); + console.log('fetch messages response:', response); + // snippet.end + const found = response.channels[channel]?.find((m) => String(m.timetoken) === timetoken); + assert.ok(found, 'the seeded message is returned'); + assert.deepEqual(found.meta, { source: 'docs-snippets' }); + assert.equal(found.customMessageType, 'text-message'); + assert.ok((found as WithActions).actions?.reaction?.smiley_face, 'the reaction is attached to the message'); + }); + + // Proves messageCounts with one timetoken for all channels. The timetoken is taken before seeding, so it counts 2 and 1. + step('messageCountBasicUsage', { needs: ['preflightPersistence'], proves: 'messageCounts with one timetoken counts the messages per channel since that time.' }, async () => { + const pubnub = world.shopper; + const [room1, room2] = [world.n('chats.room1'), world.n('chats.room2')]; + const before = (await world.observer.time()).timetoken; + const first = await seed(room1, 2); + const second = await seed(room2, 1); + await untilStored(room1, first); + await untilStored(room2, second); + world.setPh('countFrom', before); + + // snippet.messageCountBasicUsage + const result = await pubnub.messageCounts({ + channels: ['chats.room1', 'chats.room2'], + channelTimetokens: ['replace-with-timetoken'], // single timetoken applied to all channels + }); + console.log('Message counts retrieved successfully:', result); + // snippet.end + assert.deepEqual(result.channels, { [room1]: 2, [room2]: 1 }); + }); + + // Proves per-channel timetokens. Each timetoken is taken just before that channel gets 1, 2 and 3 messages, which gives the counts the assertion expects. + step('messageCountTimetokensForChannels', { needs: ['preflightPersistence'], proves: 'messageCounts with one timetoken per channel gives the per-channel counts.' }, async () => { + const pubnub = world.shopper; + const channels = [world.n('ch1'), world.n('ch2'), world.n('ch3')]; + // One timetoken per channel, taken just before that channel gets 1, 2 and 3 messages. + for (const [index, channel] of channels.entries()) { + world.setPh(`countFromCh${index + 1}`, (await world.observer.time()).timetoken); + await untilStored(channel, await seed(channel, index + 1)); + } + + // snippet.messageCountTimetokensForChannels + const response = await pubnub.messageCounts({ + channels: ['ch1', 'ch2', 'ch3'], + channelTimetokens: [ + 'replace-with-channel-timetoken-ch1', // timetoken for channel ch1 + 'replace-with-channel-timetoken-ch2', // timetoken for channel ch2 + 'replace-with-channel-timetoken-ch3', // timetoken for channel ch3 + ], + }); + console.log('message count response:', response); + // snippet.end + assert.deepEqual(response.channels, { [channels[0]]: 1, [channels[1]]: 2, [channels[2]]: 3 }); + }); + + // `start` is exclusive and `end` inclusive, so start = T - 1 and end = T delete exactly the message at T. + const deleteOne = async (): Promise<{ channel: string; token: string }> => { + const channel = world.n('ch1'); + const [token] = await seed(channel, 1); + await untilStored(channel, [token]); + world.setPh('historyStart', addToTimetoken(token, -1)); + world.setPh('historyEnd', token); + return { channel, token }; + }; + + // Waits until a deleted message is gone for readers, deletion is also eventually consistent. + const untilDeleted = (channel: string, token: string): Promise => + eventually(async () => { + const history = await world.observer.fetchMessages({ channels: [channel], count: 100 }); + assert.ok(!(history.channels[channel] ?? []).some((m) => String(m.timetoken) === token), 'message is still stored'); + }, { timeout: 30_000, label: 'message deleted' }); + + // Proves deleteMessages removes exactly the message at the boundary timetoken. + step('deleteMessagesBasicUsage', { needs: ['preflightDeleteFromHistory'], proves: 'deleteMessages removes exactly the message at the boundary timetoken.' }, async () => { + const pubnub = world.shopper; + const { channel, token } = await deleteOne(); + + // snippet.deleteMessagesBasicUsage + const result = await pubnub.deleteMessages({ + channel: 'ch1', + start: 'replace-with-start-timetoken', + end: 'replace-with-end-timetoken', + }); + console.log('Messages deleted successfully:', result); + // snippet.end + await untilDeleted(channel, token); + }); + + // Same proof as deleteMessagesBasicUsage for the page's second sample (identical call, separate docs section). + step('deleteSpecificMessages', { needs: ['preflightDeleteFromHistory'], proves: 'The second deleteMessages sample removes exactly the message at its boundary timetoken.' }, async () => { + const pubnub = world.shopper; + const { channel, token } = await deleteOne(); + + // snippet.deleteSpecificMessages + const response = await pubnub.deleteMessages({ + channel: 'ch1', + start: 'replace-with-start-timetoken', + end: 'replace-with-end-timetoken', + }); + console.log('delete messages response:', response); + // snippet.end + await untilDeleted(channel, token); + }); +}); diff --git a/docs-snippets/api-reference/misc.ts b/docs-snippets/api-reference/misc.ts new file mode 100644 index 000000000..9696f0124 --- /dev/null +++ b/docs-snippets/api-reference/misc.ts @@ -0,0 +1,101 @@ +import assert from '../harness/assert.js'; +import type PubNub from 'pubnub'; +import { page, step } from '../harness/step.js'; +import { expectStatus, sleep, watch } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/api-reference/misc.md + * + * Keyset: open. Phase: P7 "Network". In the Sneaker Drop story this is the shop app on a flaky connection: the + * shopper disconnects, reconnects and goes through a corporate proxy. + * + * What it proves: `disconnect()` stops delivery, `reconnect()` resumes it, and `setProxy()` really routes requests + * through the local CONNECT proxy the harness starts on 127.0.0.1 (counted by `world.proxy.connects`). + * What it does not prove: the outage steps for unexpected disconnect and reconnect statuses are still open, and a real corporate proxy with authentication. + */ +export default page('api-reference/misc', 'P7', (world) => { + // Shared between setProxyBasicUsage and deleteProxy, which must act on the same client. + let proxied: PubNub | undefined; + + // Proves disconnect() emits PNDisconnectedCategory and that nothing is delivered afterwards. + // The fixed 3 second sleep is a negative wait: there is no event to wait for when a message must NOT arrive. + step('disconnectBasicUsage', { proves: 'disconnect() reports a disconnected status and no message arrives afterwards.' }, async () => { + const pubnub = world.fresh('disconnect'); + const channel = world.n('ch1'); + const listening = await watch(pubnub, [channel]); + const disconnected = expectStatus(pubnub, ['PNDisconnectedCategory']); + + // snippet.disconnectBasicUsage + pubnub.disconnect(); + // snippet.end + + assert.equal(await disconnected, 'PNDisconnectedCategory'); + // While disconnected the client receives nothing. + await world.friend.publish({ channel, message: { while: 'disconnected' } }); + await sleep(3000); + assert.ok( + !listening.events.some((e) => (e.event as { message?: { while?: string } }).message?.while === 'disconnected'), + 'no message arrives while disconnected', + ); + }); + + // Proves reconnect() restores delivery. The setup disconnects first so the snippet has something to undo. + // The status listener is registered before the call because statuses are not replayed. + step('reconnectBasicUsage', { proves: 'reconnect() restores delivery after a disconnect.' }, async () => { + const pubnub = world.fresh('reconnect'); + const channel = world.n('ch1'); + const listening = await watch(pubnub, [channel]); + const disconnected = expectStatus(pubnub, ['PNDisconnectedCategory']); + pubnub.disconnect(); + await disconnected; + const back = expectStatus(pubnub, ['PNReconnectedCategory', 'PNConnectedCategory']); + + // snippet.reconnectBasicUsage + pubnub.reconnect(); + // snippet.end + + await back; + await world.friend.publish({ channel, message: { after: 'reconnect' } }); + await listening.next('message', (m) => (m.message as { after?: string }).after === 'reconnect'); + }); + + // Proves setProxy() routes traffic through the proxy. The hidden spread in the snippet replaces the docs placeholders + // (YOUR_HOSTNAME and so on) with the local proxy. A time() call is the cheapest request that needs the network. + // No `needs`: the step only uses the keys, not a keyset feature. + step('setProxyBasicUsage', { proves: 'setProxy routes the client traffic through the proxy, seen as a tunnel request.' }, async () => { + await world.proxy.start(); + const pubnub = world.fresh('proxy'); + proxied = pubnub; + + // snippet.setProxyBasicUsage + // This method is only available for NodeJS. + pubnub.setProxy({ + hostname: 'YOUR_HOSTNAME', + port: 8080, + protocol: 'YOUR_PROTOCOL', + // snippet.hide + ...world.proxy.config, + // snippet.show + }); + // snippet.end + + const tunnelsBefore = world.proxy.connects; + world.check.serverTime((await pubnub.time()).timetoken); + assert.ok(world.proxy.connects > tunnelsBefore, 'the request went through the local proxy'); + }); + + // Proves setProxy() with no argument removes the proxy: the next request adds no tunnel. + // Reuses the client of setProxyBasicUsage, hence the `needs`. + step('deleteProxy', { needs: ['setProxyBasicUsage'], proves: 'setProxy with no argument removes the proxy so the next request adds no tunnel.' }, async () => { + const pubnub = proxied; + assert.ok(pubnub, 'setProxyBasicUsage has not run'); + + // snippet.deleteProxy + pubnub.setProxy(); + // snippet.end + + const tunnelsBefore = world.proxy.connects; + world.check.serverTime((await pubnub.time()).timetoken); + assert.equal(world.proxy.connects, tunnelsBefore, 'no request goes through the proxy any more'); + }); +}); diff --git a/docs-snippets/api-reference/mobile-push.ts b/docs-snippets/api-reference/mobile-push.ts new file mode 100644 index 000000000..e3c23c0e0 --- /dev/null +++ b/docs-snippets/api-reference/mobile-push.ts @@ -0,0 +1,416 @@ +import assert from '../harness/assert.js'; +import PubNub from 'pubnub'; +import { page, step, check, skipStep } from '../harness/step.js'; +import { eventually, watch } from '../harness/wait.js'; +import { explainError } from '../harness/guard.js'; +import { DEVICE_TOKEN_EXAMPLE } from '../fixtures/names.js'; +import { registry } from '../harness/registry.js'; + +/** + * docs/sdks/javascript/api-reference/mobile-push.md + * + * WHAT THIS PAGE PROVES, AND WHAT IT DOES NOT + * - Proven live: the registration API. addChannels, listChannels, removeChannels and deleteDevice are accepted by the + * service and change the registration state, and each step reads that state back (list shows exactly the registered + * channels, remove shows them gone, deleteDevice leaves none). + * - Not tested: delivery. No push is ever sent to a device, that needs a real device and real APNs or FCM credentials. + * - Not validated: the tokens. The keyset has Push Notifications enabled WITHOUT any APNs or FCM credentials, so the + * service only checks the token FORMAT for apns and apns2 (hex string, 64 characters and also 40 pass) and accepts + * any string for fcm. Nothing is checked against Apple or Google until a push is sent, so a registered token here + * says nothing about whether it belongs to a real device. + * - Payload builders (`PubNub.notificationPayload`) are local code and run without the add-on. + * + * DOCS BUG: the original docs sample used the device `niceDevice` with `pushGateway: 'apns2'`. The real service answers that + * with 400 {"error":"Invalid device token"}. The snippet bodies here use a 64-character hex token instead + * (`DEVICE_TOKEN_EXAMPLE` in fixtures/names.ts, registered as kind `device`). Staging replaces it with a per-run hex + * token of the same length (`deviceToken`), so the value stays valid for every gateway. The check + * `pushDeviceTokenFormat` keeps the 'niceDevice' claim true over time. + * + * Samples that call a method twice (APNs2, then FCM) number the later `response` variables. + * + * Keyset: open, with the Push Notifications add-on (optional, see below). Phase: P6, with the preflight in P0. + * Sneaker Drop story: a shopper's phone registers for drop alerts on channels `a` and `b`, and the app builds the + * notification payload that would announce a drop. Steps skip as `needs-feature:push-notifications` when the add-on + * is off, which is allowed and costs no skip budget. The registration steps chain through `requireStep` and not through + * `needs`, explained at `requireStep`. The janitor removes device registrations by recomputing the per-run token. + */ +export default page('api-reference/mobile-push', 'P6', (world) => { + // Registration order is not guaranteed by the service, so every list comparison sorts first. + const sorted = (values: string[]): string[] => [...values].sort(); + // The two channels every registration step uses (docs `a` and `b`, run-suffixed). + const ab = (): string[] => sorted([world.n('a'), world.n('b')]); + /** This run's device token, as the staged snippets use it. Valid hex, same length as the literal in the docs. */ + const device = (): string => world.n(DEVICE_TOKEN_EXAMPLE); + // The APNs2 parameters the docs show, shared by the harness calls that sit outside the snippets. + const apns2Options = { pushGateway: 'apns2', environment: 'production', topic: 'com.example.bundle_id' } as const; + + // ---- add-on probe -------------------------------------------------------------------------------------------------- + // + // Push Notifications is an add-on. With it off, every registration call answers 403 "Use of the mobile push + // notifications API requires Push Notifications which is not enabled for this subscribe key". That is a keyset + // configuration gap, not a docs or SDK defect, so it is a precise `needs-feature:push-notifications` skip. + // harness/policy.ts treats that tag as an allowed skip that never counts against the skip budget, prints it in the + // end-of-run summary ("feature skip: ..."), and turns it into a failure when the run sets + // DOCS_SNIPPETS_REQUIRE_FEATURES=push-notifications. A CI job that owns a keyset with the add-on on sets that variable, + // so the push steps cannot stop running there unnoticed. Any other error (400, 5xx, network) is a real failure. + // + // The dependent steps do NOT use `needs: ['preflightMobilePush']`. A skipped upstream would turn them into + // `blocked-by:*` skips, which count against the budget and would hide that the real cause is the add-on. Each step + // calls `requirePush()` instead, which repeats the same precise feature skip. + /** + * Everything the service said about a failed call, as one string: the SDK message, the HTTP status and the body. + * The push endpoints answer errors as `{"error":"Invalid device token"}` (a string under `error`), and + * `explainError` in harness/guard.ts only reads `error_message`, `message` and `error.message`, so it misses that + * shape. Read the body here instead of widening the shared guard. + */ + function serviceText(error: unknown): string { + explainError(error); + const status = (error as { status?: { statusCode?: unknown; errorData?: unknown } } | undefined)?.status; + const body = status?.errorData === undefined ? '' : JSON.stringify(status.errorData); + const message = error instanceof Error ? error.message : String(error); + return `${message} status=${String(status?.statusCode ?? '')} body=${body}`; + } + // The service text for "add-on not enabled". Anything else is a real failure. + const PUSH_OFF = /requires Push Notifications which is not enabled/i; + // Memoized so the add-on probe runs once per run, however many steps ask. + let probe: Promise<{ off: false } | { off: true; detail: string }> | undefined; + + /** Registers and removes a hex token on fcm and apns2 once per run, and tells whether the add-on is off. */ + function pushAvailability(): Promise<{ off: false } | { off: true; detail: string }> { + probe ??= (async () => { + const client = world.shopper; + const channel = world.n('preflight_channel'); + try { + // fcm accepts any string, apns2 needs the hex format. Both use this run's hex token. + await client.push.addChannels({ channels: [channel], device: device(), pushGateway: 'fcm' }); + await client.push.addChannels({ channels: [channel], device: device(), ...apns2Options }); + return { off: false } as const; + } catch (error) { + const detail = serviceText(error); + if (PUSH_OFF.test(detail)) return { off: true, detail } as const; + throw new Error( + `A push registration with a synthetic ${DEVICE_TOKEN_EXAMPLE.length}-character hex device token was rejected, and the answer is not the "add-on not enabled" 403. ` + + `Push Notifications is on for this keyset, so this is a service or SDK problem. ${detail}`, + ); + } finally { + // Always remove what the probe registered, on both gateways, whatever happened above. + await client.push.deleteDevice({ device: device(), pushGateway: 'fcm' }).catch(() => undefined); + await client.push.deleteDevice({ device: device(), ...apns2Options }).catch(() => undefined); + } + })(); + return probe; + } + + /** Skips the calling step (precise feature skip) when the add-on is off. Rethrows real errors. */ + async function requirePush(): Promise { + const result = await pushAvailability(); + if (result.off) { + skipStep( + 'needs-feature:push-notifications', + `the add-on is off for the open keyset, so registration was not exercised (${result.detail}). Enable Push Notifications for it in the Admin Portal, no APNs or FCM credentials are needed.`, + ); + } + // Leave a marker that names this run id on a channel (App Context). The janitor lists channel metadata by pattern + // and finds the run ids of crashed runs from it. Push registrations cannot be listed without the token, so + // without a marker a crashed push-only run could never be swept. Best effort: no App Context only loses the sweep. + await world.observer.objects + .setChannelMetadata({ channel: world.n('push_marker'), data: { name: 'mobile push run marker' } }) + .catch(() => undefined); + } + + // Wired through requirePush so a keyset without the add-on skips here as needs-feature and not as a failure. + check('preflightMobilePush', { phase: 'P0', timeout: 60_000, proves: 'Preflight: the keyset has the Mobile Push add-on, otherwise push steps skip as needs-feature.' }, async () => { + // Registers this run's hex token on the preflight channel for fcm and apns2, then removes it again. + // Passing means: the add-on is on and accepts synthetic tokens without credentials. Skipping means: add-on off. + await requirePush(); + }); + + // Harness-only. Not a docs sample: it pins the claim behind the docs bug so the claim cannot go stale. + check('pushDeviceTokenFormat', { timeout: 60_000, proves: 'Pins which device token formats each push gateway accepts, behind a documented docs problem.' }, async () => { + // Keeps the docs-bug claim true over time: the docs token 'niceDevice' is rejected for apns and apns2 (400 + // "Invalid device token") and accepted for fcm. If the service ever changes either way, this fails and the + // docs claim must be revisited. 'niceDevice' is used literally for the rejections, nothing is registered. + await requirePush(); + const client = world.shopper; + const channel = world.n('a'); + const docsToken = 'niceDevice'; + for (const [label, options] of [['apns', { pushGateway: 'apns' }], ['apns2', apns2Options]] as const) { + let rejected: unknown; + try { + await client.push.addChannels({ channels: [channel], device: docsToken, ...options }); + } catch (error) { + rejected = error; + } + assert.ok(rejected, `${label} accepted the docs token "${docsToken}", the docs bug is gone, update the docs`); + const detail = serviceText(rejected); + assert.match(detail, /status=400/, `${label}: expected status 400, got ${detail}`); + assert.match(detail, /Invalid device token/i, `${label}: expected "Invalid device token", got ${detail}`); + } + // fcm takes any string. Use a run-suffixed copy so parallel runs do not share a registration, then remove it. + const fcmToken = world.n(docsToken); + try { + await client.push.addChannels({ channels: [channel], device: fcmToken, pushGateway: 'fcm' }); + const listed = await world.observer.push.listChannels({ device: fcmToken, pushGateway: 'fcm' }); + assert.deepEqual(listed.channels, [channel], 'fcm registered a non-hex token'); + } finally { + await client.push.deleteDevice({ device: fcmToken, pushGateway: 'fcm' }).catch(() => undefined); + } + }); + + /** + * The registration steps build on each other (add, list, remove, delete). When an earlier one failed, skip with + * `blocked-by` so one root cause shows once. Not a `needs`, see the add-on probe comment above. + */ + function requireStep(id: string): void { + const result = registry.result(id); + if (result && result.status !== 'passed') skipStep(`blocked-by:${id}`); + } + + // The id keeps the docs' misspelling ("Decive") because the docs embed it by that name. First step of the chain. + // The snippet shows a hex token, staging swaps in this run's token, and the read-back uses another client + // (`eventually`, because the registration becomes listable shortly after the call). + step('addDeciveToChannelBasicUsage', { timeout: 60_000, proves: 'addChannels registers a device token for a channel and another client reads the registration back.' }, async () => { + const pubnub = world.shopper; + await requirePush(); + // snippet.addDeciveToChannelBasicUsage + // Function to add a device to a channel for APNs2 + const response = await pubnub.push.addChannels({ + channels: ['a', 'b'], + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'apns2', + environment: 'production', + topic: 'com.example.bundle_id', + }); + console.log('device added to channels response:', response); + + // Function to add a device to a channel for FCM + const response2 = await pubnub.push.addChannels({ + channels: ['a', 'b'], + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'fcm', + }); + console.log('device added to channels response:', response2); + // snippet.end + // Read-back: a list call per gateway must show exactly the two registered channels (this run's suffixed a and b), + // nothing more. This proves the registration changed state on the service. It does not prove delivery. + await eventually(async () => { + const apns2 = await world.observer.push.listChannels({ + device: device(), + pushGateway: 'apns2', + environment: 'production', + topic: 'com.example.bundle_id', + }); + assert.deepEqual(sorted(apns2.channels), ab(), 'apns2 registration'); + const fcm = await world.observer.push.listChannels({ device: device(), pushGateway: 'fcm' }); + assert.deepEqual(sorted(fcm.channels), ab(), 'fcm registration'); + }, { label: 'registrations visible' }); + }); + + // Chain step 2. The snippet's own list responses are the assertion target, no second client needed. + step('listChannelsForDeviceBasicUsage', { timeout: 60_000, proves: 'listChannels returns the channels registered for the device.' }, async () => { + const pubnub = world.shopper; + await requirePush(); + requireStep('addDeciveToChannelBasicUsage'); + // snippet.listChannelsForDeviceBasicUsage + // for APNs2 + const response = await pubnub.push.listChannels({ + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'apns2', + environment: 'production', + topic: 'com.example.bundle_id', + }); + console.log('listing channels for device response:', response); + response.channels.forEach((channel) => { + console.log(channel); + }); + + // for FCM + const response2 = await pubnub.push.listChannels({ + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'fcm', + }); + + console.log('listing channels for device response:', response2); + + response2.channels.forEach((channel) => { + console.log(channel); + }); + // snippet.end + // The snippet's own list answers must carry exactly the registered channels, for both gateways. + assert.deepEqual(sorted(response.channels), ab(), 'apns2 list'); + assert.deepEqual(sorted(response2.channels), ab(), 'fcm list'); + }); + + // Chain step 3. Proves removal by reading with another client until both gateways list nothing. + step('removeDeviceFromChannelBasicUsage', { timeout: 60_000, proves: 'removeChannels unregisters the device and both gateways list nothing afterwards.' }, async () => { + const pubnub = world.shopper; + await requirePush(); + requireStep('listChannelsForDeviceBasicUsage'); + // snippet.removeDeviceFromChannelBasicUsage + // for APNs2 + const response = await pubnub.push.removeChannels({ + channels: ['a', 'b'], + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'apns2', + environment: 'production', + topic: 'com.example.bundle_id', + }); + + console.log('removing device from channel response:', response); + + // for FCM + const response2 = await pubnub.push.removeChannels({ + channels: ['a', 'b'], + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'fcm', + }); + + console.log('removing device from channel response:', response2); + // snippet.end + // Read-back: after removeChannels a fresh list per gateway (other client) must be empty. + await eventually(async () => { + const apns2 = await world.observer.push.listChannels({ + device: device(), + pushGateway: 'apns2', + environment: 'production', + topic: 'com.example.bundle_id', + }); + assert.deepEqual(apns2.channels, []); + const fcm = await world.observer.push.listChannels({ device: device(), pushGateway: 'fcm' }); + assert.deepEqual(fcm.channels, []); + }, { label: 'registrations removed' }); + }); + + // Chain step 4. Step 3 emptied the device, so the friend client registers it again OUTSIDE the markers. Otherwise + // deleteDevice would have nothing to delete and the final empty list would prove nothing. + step('removeAllMobilePushNotificationsBasicUsage', { timeout: 60_000, proves: 'deleteDevice removes every registration of the device and the list is empty afterwards.' }, async () => { + const pubnub = world.shopper; + await requirePush(); + requireStep('removeDeviceFromChannelBasicUsage'); + // Register again (one channel, other client) so deleteDevice has something to remove, and prove it is there. + await world.friend.push.addChannels({ channels: [world.n('a')], device: device(), ...apns2Options }); + await world.friend.push.addChannels({ channels: [world.n('a')], device: device(), pushGateway: 'fcm' }); + await eventually(async () => { + const apns2 = await world.observer.push.listChannels({ device: device(), ...apns2Options }); + assert.deepEqual(apns2.channels, [world.n('a')], 'apns2 registered before deleteDevice'); + const fcm = await world.observer.push.listChannels({ device: device(), pushGateway: 'fcm' }); + assert.deepEqual(fcm.channels, [world.n('a')], 'fcm registered before deleteDevice'); + }, { label: 'registrations visible before deleteDevice' }); + // snippet.removeAllMobilePushNotificationsBasicUsage + + // for APNs2 + const response = await pubnub.push.deleteDevice({ + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'apns2', + environment: 'production', + topic: 'com.example.bundle_id', + }); + + console.log('deleteDevice response:', response); + + // for FCM + const response2 = await pubnub.push.deleteDevice({ + device: '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad', + pushGateway: 'fcm', + }); + + console.log('deleteDevice response:', response2); + + // snippet.end + // Read-back: deleteDevice must leave no channel on either gateway. + await eventually(async () => { + const apns2 = await world.observer.push.listChannels({ device: device(), ...apns2Options }); + assert.deepEqual(apns2.channels, [], 'apns2 device deleted'); + const fcm = await world.observer.push.listChannels({ device: device(), pushGateway: 'fcm' }); + assert.deepEqual(fcm.channels, [], 'fcm device deleted'); + }, { label: 'device deleted' }); + }); + + // Local payload builder plus a real publish (no add-on needed). The observer subscribes first so the message cannot + // be missed, and the message is matched by timetoken. This step does not use requirePush. + step('buildNotificationPayloadBasicUsage', { proves: 'The notification payload builder output is published and a subscriber receives the message.' }, async () => { + const pubnub = world.shopper; + const listening = await watch(world.fresh('pushobserver'), [world.n('chat-bot')]); + // snippet.buildNotificationPayloadBasicUsage + + const builder = PubNub.notificationPayload('Chat invitation', "You have been invited to 'quiz' chat"); + builder.apns.configurations = [{ targets: [{ topic: 'com.example.bundle_id' }] }]; + const messagePayload = builder.buildPayload(['apns2', 'fcm']); + // add required fields to the payload + const response = await pubnub.publish({ + message: messagePayload, + channel: 'chat-bot', + }); + console.log('publish response:', response); + // snippet.end + world.check.timetoken(response.timetoken); + assert.ok(messagePayload.pn_apns && messagePayload.pn_fcm, 'the payload carries the APNs and FCM sections'); + const received = await listening.next('message', (m) => m.timetoken === String(response.timetoken)); + assert.ok(received.message, 'subscribers get the message'); + listening.stop(); + }); + + // ---- payload builders, local --------------------------------------------------------------------------------------- + + // Local only. console.log is captured so the printed payload can be parsed and compared. The snippet prints the + // builder's payload, and console is restored before the assertions. + step('simpleNotificationPayloadFCMandAPNS', { proves: 'The payload builder prints a payload with the FCM and APNs sections the sample describes.' }, async () => { + const logs = world.captureConsole('log'); + // snippet.simpleNotificationPayloadFCMandAPNS + const builder = PubNub.notificationPayload('Chat invitation', "You have been invited to 'quiz' chat"); + builder.sound = 'default'; + + console.log(JSON.stringify(builder.buildPayload(['apns', 'fcm']), null, 2)); + // snippet.end + logs.restore(); + const alert = { title: 'Chat invitation', body: "You have been invited to 'quiz' chat" }; + assert.deepEqual(JSON.parse(logs.lines[0]), { + pn_apns: { aps: { alert, sound: 'default' } }, + pn_fcm: { notification: alert, android: { notification: { sound: 'default' } } }, + }); + }); + + // The id contains a slash, which the marker lint and the docs extractor both accept. Checks the APNs HTTP/2 section. + step('simpleNotificationPayloadFCMandAPNSH/2', { proves: 'The payload builder produces the APNs HTTP/2 section as documented.' }, async () => { + const logs = world.captureConsole('log'); + // snippet.simpleNotificationPayloadFCMandAPNSH/2 + const payloadBuilder = PubNub.notificationPayload('Chat invitation', "You have been invited to 'quiz' chat"); + payloadBuilder.apns.configurations = [{ targets: [{ topic: 'com.meetings.chat.app' }] }]; + payloadBuilder.sound = 'default'; + + console.log(JSON.stringify(payloadBuilder.buildPayload(['apns2', 'fcm']), null, 2)); + // snippet.end + logs.restore(); + const payload = JSON.parse(logs.lines[0]); + assert.equal(payload.pn_apns.pn_push[0].targets[0].topic, 'com.meetings.chat.app'); + assert.equal(payload.pn_apns.pn_push[0].version, 'v2'); + // The Output block of the page shows `sound` in `pn_apns.aps` and in `pn_fcm.android.notification`. + assert.equal(payload.pn_apns.aps.sound, 'default'); + assert.equal(payload.pn_fcm.android.notification.sound, 'default'); + }); + + // Checks that collapseId and expirationDate reach the payload in the snake case wire format. + step('simpleNotificationPayloadFCMandAPNSH/2CustomConfiguration', { proves: 'collapseId and expirationDate reach the payload in the wire format.' }, async () => { + const logs = world.captureConsole('log'); + // snippet.simpleNotificationPayloadFCMandAPNSH/2CustomConfiguration + const configuration = [ + { + collapseId: 'invitations', + expirationDate: new Date(Date.now() + 10000), + targets: [{ topic: 'com.meetings.chat.app' }], + }, + ]; + const customConfigurationBuilder = PubNub.notificationPayload( + 'Chat invitation', + "You have been invited to 'quiz' chat", + ); + customConfigurationBuilder.apns.configurations = configuration; + + console.log(JSON.stringify(customConfigurationBuilder.buildPayload(['apns2', 'fcm']), null, 2)); + // snippet.end + logs.restore(); + const payload = JSON.parse(logs.lines[0]); + assert.equal(payload.pn_apns.pn_push[0].collapse_id, 'invitations'); + assert.equal(payload.pn_apns.pn_push[0].expiration, configuration[0].expirationDate.toISOString()); + }); +}); diff --git a/docs-snippets/api-reference/objects.ts b/docs-snippets/api-reference/objects.ts new file mode 100644 index 000000000..188c2c4a7 --- /dev/null +++ b/docs-snippets/api-reference/objects.ts @@ -0,0 +1,481 @@ +import assert from '../harness/assert.js'; +import { page, step, check } from '../harness/step.js'; +import { eventually, watch } from '../harness/wait.js'; +import { explainError } from '../harness/guard.js'; + +/** + * docs/sdks/javascript/api-reference/objects.md (App Context) + * + * Runs on the open keyset, which has App Context enabled with events. The page talks about a user `myUuid` + * (and `my-uuid`), channels `team.blue` and `team.red`, and members `uuid-1` to `uuid-3`. Every step seeds what + * its snippet reads, so the steps do not depend on a particular object being left over by a neighbour. + * Snippets that call a method twice (the config user and a passed-in user) number the later `response` + * variables `response2` and `response3`, because the try/catch wrappers of the old docs files are gone. + * + * Removal snippets run in P9 so reads in P3 still see the objects. + * + * Keyset: open (App Context on, checked by `preflightAppContext`). Phase: P3 for reads and writes, P0 for the preflight, + * P9 for removals. Sneaker Drop story: shoppers are users, drop channels such as `team.blue` carry metadata, and + * memberships say who follows which drop. Proves: each App Context method does what the page says, checked by a + * second client (`world.observer`) that reads the stored state, and that set events reach a subscriber. + * Deliberately does not prove: the DataSync model of users, channels and memberships (datasync.ts), or Access + * Manager enforcement on objects. Every object id is registered in fixtures/names.ts, so the janitor can remove + * leftovers and report residue. + */ +export default page('api-reference/objects', 'P3', (world) => { + // Sorted ids, so list assertions do not depend on service ordering. + const ids = (list: Array<{ id: string }>): string[] => list.map((o) => o.id).sort(); + + // Moved to P0 so a keyset without App Context fails once with a named cause. Steps below `need` the fixture, which + // `needs` this check, so one root cause shows as one failure. + check('preflightAppContext', { phase: 'P0', proves: 'Preflight: the open keyset has App Context enabled, otherwise the App Context steps are blocked.' }, async () => { + try { + await world.observer.objects.getAllUUIDMetadata({ limit: 1 }); + } catch (error) { + explainError(error); + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`The open keyset does not have App Context. Enable "App Context" for it. ${detail}`); + } + }); + + // Seeds `team.blue` for every read step. `eventually` waits for the write to be readable from another client + // (App Context reads are eventually consistent after a write). + check('appContextFixture', { needs: ['preflightAppContext'], proves: 'Fixture: a seed channel exists and is readable from another client.' }, async () => { + // The channel the docs read (`team.blue`), named so that the "*Team" filter finds it. + await world.friend.objects.setChannelMetadata({ + channel: world.n('team.blue'), + data: { name: 'Blue Team', description: 'The channel for Blue team.' }, + }); + await eventually(async () => { + const blue = await world.observer.objects.getChannelMetadata({ channel: world.n('team.blue') }); + assert.equal(blue.data.name, 'Blue Team'); + }, { label: 'team.blue visible' }); + }); + + // ---- users ---------------------------------------------------------------------------------------------------- + + // Writes the shopper's own user and `myUuid`. The observer reads `myUuid` back, which proves the write went to the + // service and not only to the response. + step('setUUIDMetadataBasicUsage', { needs: ['appContextFixture'], proves: 'setUUIDMetadata stores the user and another client reads it back.' }, async () => { + const pubnub = world.shopper; + // snippet.setUUIDMetadataBasicUsage + // Using UUID from the config - default when uuid is not passed in the method + const response = await pubnub.objects.setUUIDMetadata({ + data: { + name: 'John Doe', + }, + }); + console.log('setUUIDMetadata response:', response); + + // Using the passed in UUID + const response2 = await pubnub.objects.setUUIDMetadata({ + uuid: 'myUuid', + data: { + email: 'john.doe@example.com', + }, + }); + console.log('setUUIDMetadata response:', response2); + // snippet.end + assert.equal(response.data.id, pubnub.getUserId()); + assert.equal(response.data.name, 'John Doe'); + assert.equal(response2.data.id, world.n('myUuid')); + assert.equal(response2.data.email, 'john.doe@example.com'); + // Another client sees the stored objects. + // App Context reads lag behind writes, so the read-back by another client is polled (seen in CI, where steps run fast). + await eventually(async () => { + const stored = await world.observer.objects.getUUIDMetadata({ uuid: world.n('myUuid') }); + assert.equal(stored.data.email, 'john.doe@example.com'); + }, { label: 'stored user visible to another client' }); + }); + + // Reads what the previous step stored, which is why it `needs` it. + step('getUUIDMetadataBasicUsage', { needs: ['setUUIDMetadataBasicUsage'], proves: 'getUUIDMetadata reads the user the previous step stored.' }, async () => { + const pubnub = world.shopper; + // snippet.getUUIDMetadataBasicUsage + // Using UUID from the config - default when uuid is not passed in the method + const response = await pubnub.objects.getUUIDMetadata(); + console.log('getUUIDMetadata response:', response); + + // Using the passed in UUID + const response2 = await pubnub.objects.getUUIDMetadata({ + uuid: 'myUuid', + }); + console.log('getUUIDMetadata response:', response2); + // snippet.end + assert.equal(response.data.id, pubnub.getUserId()); + assert.equal(response.data.name, 'John Doe'); + assert.equal(response2.data.id, world.n('myUuid')); + assert.equal(response2.data.email, 'john.doe@example.com'); + }); + + // The snippet defines a function and awaits it, so a failure rejects instead of becoming an unhandled rejection. + // Console is captured to prove the function logged its response. + step('getAllUUIDMetadataBasicUsage', { needs: ['setUUIDMetadataBasicUsage'], proves: 'getAllUUIDMetadata runs the sample function and logs the user list.' }, async () => { + const pubnub = world.shopper; + const logs = world.captureConsole('log'); + try { + // snippet.getAllUUIDMetadataBasicUsage + // Function to get all UUID metadata + // to get some data in response, add user metadata using setUUIDMetadata method + async function getAllUUIDMetadata() { + const response = await pubnub.objects.getAllUUIDMetadata(); + console.log('getAllUUIDMetadata response:', response); + } + + // Execute the function to get UUID metadata + await getAllUUIDMetadata(); + // snippet.end + // The snippet awaits the call, so the log line is there when it ends and a rejection fails the step. + assert.ok(logs.lines.some((line) => line.startsWith('getAllUUIDMetadata response:')), 'the function logged its response'); + } finally { + logs.restore(); + } + const listed = await pubnub.objects.getAllUUIDMetadata({ limit: 100, sort: { updated: 'desc' } }); + assert.ok(listed.data.some((u) => u.id === world.n('myUuid')), 'the stored user is part of the list'); + }); + + // P9: removes the shopper and `myUuid` after every read step is done. `eventually` waits until reads fail with not found. + step('removeUUIDMetadataBasicUsage', { phase: 'P9', needs: ['getUUIDMetadataBasicUsage', 'getAllUUIDMetadataBasicUsage'], proves: 'removeUUIDMetadata deletes the users and reads fail with not found afterwards.' }, async () => { + const pubnub = world.shopper; + // snippet.removeUUIDMetadataBasicUsage + // Using UUID from the config - default when uuid is not passed in the method + const response = await pubnub.objects.removeUUIDMetadata(); + + // Using the passed in UUID + const response2 = await pubnub.objects.removeUUIDMetadata({ + uuid: 'myUuid', + }); + // snippet.end + assert.equal(response.status, 200); + assert.equal(response2.status, 200); + await eventually(async () => { + for (const uuid of [pubnub.getUserId(), world.n('myUuid')]) { + await assert.rejects(world.observer.objects.getUUIDMetadata({ uuid }), `${uuid} is gone`); + } + }, { label: 'users removed' }); + }); + + // ---- channels ------------------------------------------------------------------------------------------------- + + // The `*Team` filter needs a channel named `... Team`, which the fixture created with the name `Blue Team`. + step('getAllChannelMetadataBasicUsage', { needs: ['appContextFixture'], proves: 'getAllChannelMetadata with the Team filter returns the seeded channel.' }, async () => { + const pubnub = world.shopper; + // snippet.getAllChannelMetadataBasicUsage + // Get the total number of channels included in the response. + const response = await pubnub.objects.getAllChannelMetadata({ + include: { + totalCount: true, + }, + }); + + // Get all channels with the filter option. To get all channel which has Id ending 'Team'. + const response2 = await pubnub.objects.getAllChannelMetadata({ + filter: 'name LIKE "*Team"', + }); + console.log('Get all channel metadata response:', response2); + // snippet.end + assert.ok((response.totalCount ?? 0) >= 1, 'totalCount is part of the response'); + assert.ok(response2.data.length >= 1, 'the filter finds the team channels'); + assert.ok(response2.data.every((c) => c.name?.endsWith('Team')), 'every match ends with Team'); + assert.ok(ids(response2.data).includes(world.n('team.blue')), 'team.blue is in the filtered list'); + }); + + // Reads the fixture channel. The snippet comment is a docs point: `channel` is the id, not the name. + step('getChannelMetadataBasicUsage', { needs: ['appContextFixture'], proves: 'getChannelMetadata reads the seeded channel by id.' }, async () => { + const pubnub = world.shopper; + // snippet.getChannelMetadataBasicUsage + const response = await pubnub.objects.getChannelMetadata({ + // `channel` is the `id` in the _metadata_, not `name` + channel: 'team.blue', + }); + console.log('Get channel metadata response:', response); + // snippet.end + assert.equal(response.data.id, world.n('team.blue')); + assert.equal(response.data.name, 'Blue Team'); + }); + + // The observer subscribes first so the set event cannot be missed. The snippet asks for `customFields: false`, so + // the response hides `custom` and a second read proves it was stored anyway. + step('setChannelMetadataBasicUsage', { needs: ['appContextFixture'], proves: 'setChannelMetadata stores the channel, an observer gets the set event and the hidden custom data was stored.' }, async () => { + const pubnub = world.shopper; + // Channel scoped objects events arrive on a subscription to the channel itself. + const listening = await watch(world.fresh('objectsobserver'), [world.n('team.red')]); + // snippet.setChannelMetadataBasicUsage + const response = await pubnub.objects.setChannelMetadata({ + channel: 'team.red', + data: { + name: 'Red Team', + description: 'The channel for Red team and no other teams.', + custom: { + owner: 'Red Leader', + }, + }, + include: { + customFields: false, + }, + }); + console.log('Set channel metadata response:', response); + // snippet.end + assert.equal(response.data.id, world.n('team.red')); + assert.equal(response.data.name, 'Red Team'); + assert.equal(response.data.custom, undefined, 'customFields: false leaves custom out of the response'); + // App Context reads lag behind writes, so the read-back by another client is polled (seen in CI, where steps run fast). + await eventually(async () => { + const stored = await world.observer.objects.getChannelMetadata({ channel: world.n('team.red'), include: { customFields: true } }); + assert.deepEqual(stored.data.custom, { owner: 'Red Leader' }); + }, { label: 'stored channel custom data visible to another client' }); + const event = await listening.next('objects', (e) => e.channel === world.n('team.red')); + assert.equal(event.message.event, 'set'); + assert.equal(event.message.type, 'channel'); + listening.stop(); + }); + + // Read-modify-write pattern from the page. `needs` the set step so `team.red` exists, then checks that the merged + // custom object survived on the service. + step('iterativelyUpdateExistingMetadata', { needs: ['setChannelMetadataBasicUsage'], proves: 'The read-modify-write pattern merges custom data and the merged object survives on the service.' }, async () => { + const pubnub = world.shopper; + // snippet.iterativelyUpdateExistingMetadata + const channel = 'team.red'; + const name = 'Red Team'; + const description = 'The channel for Red team.'; + const customField = { visible: 'team' }; + + // Function to set and then update channel metadata + const response = await pubnub.objects.setChannelMetadata({ + channel: channel, + data: { + name: name, + description: description, + custom: customField, + }, + }); + console.log('The channel has been created with name and description.\n'); + + // Fetch current object with custom fields + const currentObjectResponse = await pubnub.objects.getChannelMetadata({ + channel: channel, + include: { + customFields: true, + }, + }); + const currentObject = currentObjectResponse.data; + + // Initialize the custom field object + const custom = currentObject.custom || {}; + + // Add or update the field + custom['edit'] = 'admin'; + + // Writing the updated object back to the server + const setResponse = await pubnub.objects.setChannelMetadata({ + channel: channel, + data: { + name: currentObject.name || '', + description: currentObject.description || '', + custom: custom, + }, + }); + console.log('Object has been updated', setResponse); + // snippet.end + assert.equal(response.status, 200); + // App Context reads lag behind writes, so the read-back by another client is polled (seen in CI, where steps run fast). + await eventually(async () => { + const stored = await world.observer.objects.getChannelMetadata({ channel: world.n('team.red'), include: { customFields: true } }); + assert.deepEqual(stored.data.custom, { visible: 'team', edit: 'admin' }); + assert.equal(stored.data.description, 'The channel for Red team.'); + }, { label: 'updated channel metadata visible to another client' }); + }); + + // P9 cleanup of `team.red`, polled until the read fails. + step('removeChannelMetadataBasicUsage', { phase: 'P9', needs: ['iterativelyUpdateExistingMetadata'], proves: 'removeChannelMetadata deletes the channel and reads fail afterwards.' }, async () => { + const pubnub = world.shopper; + // snippet.removeChannelMetadataBasicUsage + const response = await pubnub.objects.removeChannelMetadata({ + channel: 'team.red', + }); + // snippet.end + assert.equal(response.status, 200); + await eventually(async () => { + await assert.rejects(world.observer.objects.getChannelMetadata({ channel: world.n('team.red') })); + }, { label: 'team.red removed' }); + }); + + // ---- memberships ---------------------------------------------------------------------------------------------- + + // The default response does not carry `type` or `custom`, so a second read with include flags proves they were stored. + // That is a docs finding. + step('setMembershipBasicUsage', { needs: ['appContextFixture'], proves: 'setMemberships stores a membership, and a second read with include flags shows the type and custom data.' }, async () => { + const pubnub = world.shopper; + // snippet.setMembershipBasicUsage + // Using UUID from the config + const response = await pubnub.objects.setMemberships({ + channels: [ + 'my-channel', + { id: 'channel-with-status-type', custom: { hello: 'World' }, status: 'helloStatus', type: 'helloType' }, + ], + }); + console.log('Set memberships response:', response); + + // Using the passed in UUID + const response2 = await pubnub.objects.setMemberships({ + uuid: 'my-uuid', + channels: [ + 'my-channel', + { id: 'channel-with-status-type', custom: { hello: 'World' }, status: 'helloStatus', type: 'helloType' }, + ], + include: { + // To include channel fields in response + channelFields: true, + }, + }); + console.log('Set memberships response:', response2); + // snippet.end + const expected = [world.n('channel-with-status-type'), world.n('my-channel')].sort(); + assert.deepEqual(response.data.map((m) => m.channel.id).sort(), expected); + assert.deepEqual(response2.data.map((m) => m.channel.id).sort(), expected); + // The default response carries the membership status. `type` and `custom` need their include flags, + // so a second read proves that they were stored (live finding). + assert.equal(response.data.find((m) => m.channel.id === world.n('channel-with-status-type'))?.status, 'helloStatus'); + // App Context reads lag behind writes, so the read-back by another client is polled (seen in CI, where steps run fast). + await eventually(async () => { + const stored = await world.observer.objects.getMemberships({ + uuid: world.n('my-uuid'), + include: { typeField: true, statusField: true, customFields: true }, + }); + assert.deepEqual(stored.data.map((m) => m.channel.id).sort(), expected); + const special = stored.data.find((m) => m.channel.id === world.n('channel-with-status-type')); + assert.equal(special?.status, 'helloStatus'); + assert.equal(special?.type, 'helloType'); + assert.deepEqual(special?.custom, { hello: 'World' }); + }, { label: 'stored memberships visible to another client' }); + }); + + // The friend client seeds `myUuid` with a starred membership OUTSIDE the markers, so the filter `custom.starred == true` + // has exactly one hit. + step('getMembershipBasicUsage', { needs: ['setMembershipBasicUsage'], proves: 'getMemberships with the starred filter returns exactly the one starred membership.' }, async () => { + const pubnub = world.shopper; + // Seed the user the docs read: `myUuid` has one starred membership. + await world.friend.objects.setMemberships({ + uuid: world.n('myUuid'), + channels: [{ id: world.n('my-channel'), custom: { starred: true } }, { id: world.n('channel-with-status-type'), custom: { starred: false } }], + }); + // snippet.getMembershipBasicUsage + // Using UUID from the config + const response = await pubnub.objects.getMemberships(); + + // Using the passed in UUID + const response2 = await pubnub.objects.getMemberships({ + uuid: 'myUuid', + include: { + channelFields: true, + }, + }); + + // Get all memberships that are starred by the user + const response3 = await pubnub.objects.getMemberships({ + uuid: 'myUuid', + filter: 'custom.starred == true', + }); + // snippet.end + assert.deepEqual(response.data.map((m) => m.channel.id).sort(), [world.n('channel-with-status-type'), world.n('my-channel')].sort()); + assert.equal(response2.data.length, 2); + assert.ok(response2.data.every((m) => m.channel.id), 'channel ids are part of the response'); + assert.deepEqual(response3.data.map((m) => m.channel.id), [world.n('my-channel')]); + }); + + // P9. Re-creates `ch-1` and `ch-2` memberships for both users first, because the snippet removes them and no earlier + // step guarantees them. `my-channel` must stay. + step('removeMembershipsBasicUsage', { phase: 'P9', needs: ['getMembershipBasicUsage'], proves: 'removeMemberships removes the named memberships and keeps the others.' }, async () => { + const pubnub = world.shopper; + for (const uuid of [pubnub.getUserId(), world.n('myUuid')]) { + await world.friend.objects.setMemberships({ uuid, channels: [world.n('ch-1'), world.n('ch-2'), world.n('my-channel')] }); + } + // snippet.removeMembershipsBasicUsage + // Using UUID from the config + const response = await pubnub.objects.removeMemberships({ + channels: ['ch-1', 'ch-2'], + }); + + // Using the passed in UUID + const response2 = await pubnub.objects.removeMemberships({ + uuid: 'myUuid', + channels: ['ch-1', 'ch-2'], + }); + // snippet.end + for (const result of [response, response2]) { + assert.ok(!result.data.some((m) => [world.n('ch-1'), world.n('ch-2')].includes(m.channel.id)), 'ch-1 and ch-2 are gone'); + assert.ok(result.data.some((m) => m.channel.id === world.n('my-channel')), 'my-channel stays'); + } + await eventually(async () => { + const left = await world.observer.objects.getMemberships({ uuid: world.n('myUuid') }); + assert.ok(!left.data.some((m) => m.channel.id === world.n('ch-1'))); + }, { label: 'memberships removed' }); + }); + + // ---- channel members ------------------------------------------------------------------------------------------ + + // Seeds two members with names so the `*admin*` filter matches only one. Seeding is outside the markers and done + // by the friend client, so the shopper's snippet reads data it did not write. + step('getChannelMembersBasicUsage', { needs: ['appContextFixture'], proves: 'getChannelMembers with the admin filter returns only the matching member.' }, async () => { + const pubnub = world.shopper; + await world.friend.objects.setUUIDMetadata({ uuid: world.n('uuid-1'), data: { name: 'Member One', custom: { role: 'Member' } } }); + await world.friend.objects.setUUIDMetadata({ uuid: world.n('uuid-2'), data: { name: 'Team admin' } }); + await world.friend.objects.setChannelMembers({ + channel: world.n('myChannel'), + uuids: [world.n('uuid-1'), world.n('uuid-2')], + }); + // snippet.getChannelMembersBasicUsage + const response = await pubnub.objects.getChannelMembers({ + channel: 'myChannel', + include: { + UUIDFields: true, + }, + }); + console.log('getChannelMembers response:', response); + + // Get all channel members with "admin" in the user name + const response2 = await pubnub.objects.getChannelMembers({ + channel: 'myChannel', + filter: 'uuid.name LIKE "*admin*"', + }); + console.log('getChannelMembers response:', response2); + // snippet.end + assert.deepEqual(response.data.map((m) => m.uuid.id).sort(), [world.n('uuid-1'), world.n('uuid-2')].sort()); + assert.equal((response.data.find((m) => m.uuid.id === world.n('uuid-1'))?.uuid as { name?: string } | undefined)?.name, 'Member One'); + assert.deepEqual(response2.data.map((m) => m.uuid.id), [world.n('uuid-2')], 'the name filter keeps the admin only'); + }); + + // Adds `uuid-3` with a custom role, then reads it back through the observer. + step('setChannelMembersBasicUsage', { needs: ['getChannelMembersBasicUsage'], proves: 'setChannelMembers adds a member with a custom role and another client reads it back.' }, async () => { + const pubnub = world.shopper; + // snippet.setChannelMembersBasicUsage + const response = await pubnub.objects.setChannelMembers({ + channel: 'myChannel', + uuids: ['uuid-1', 'uuid-2', { id: 'uuid-3', custom: { role: 'Super Admin' } }], + }); + console.log('setChannelMembers response:', response); + // snippet.end + assert.deepEqual(response.data.map((m) => m.uuid.id).sort(), [world.n('uuid-1'), world.n('uuid-2'), world.n('uuid-3')].sort()); + // App Context reads lag behind writes, so the read-back by another client is polled (seen in CI, where steps run fast). + await eventually(async () => { + const stored = await world.observer.objects.getChannelMembers({ channel: world.n('myChannel'), include: { customFields: true } }); + assert.deepEqual(stored.data.find((m) => m.uuid.id === world.n('uuid-3'))?.custom, { role: 'Super Admin' }); + }, { label: 'stored channel members visible to another client' }); + }); + + // P9. Only `uuid-3` remains, which also shows that removal is by id and does not touch other members. + step('removeChannelMembersBasicUsage', { phase: 'P9', needs: ['setChannelMembersBasicUsage'], proves: 'removeChannelMembers removes one member by id and leaves the others.' }, async () => { + const pubnub = world.shopper; + // snippet.removeChannelMembersBasicUsage + const response = await pubnub.objects.removeChannelMembers({ + channel: 'myChannel', + uuids: ['uuid-1', 'uuid-2'], + }); + // snippet.end + assert.deepEqual(response.data.map((m) => m.uuid.id), [world.n('uuid-3')]); + await eventually(async () => { + const left = await world.observer.objects.getChannelMembers({ channel: world.n('myChannel') }); + assert.deepEqual(left.data.map((m) => m.uuid.id), [world.n('uuid-3')]); + }, { label: 'members removed' }); + }); +}); diff --git a/docs-snippets/api-reference/presence.ts b/docs-snippets/api-reference/presence.ts new file mode 100644 index 000000000..45fbfc0dc --- /dev/null +++ b/docs-snippets/api-reference/presence.ts @@ -0,0 +1,231 @@ +import assert from '../harness/assert.js'; +import type PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { connected, eventually, PRESENCE_TIMEOUT } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/api-reference/presence.md + * + * Keyset: open (needs Presence on, hence `preflightPresence`). Phase: P4, with teardown in P9. In the Sneaker Drop story + * this is the live "who is browsing the drop" counter. + * + * The docs samples ask about a user called `uuid` on channels `ch1`, `my_channel` and groups `cg1`, + * `my_channel_group`. The `presenceFixture` check makes that user real: one client with the user id `uuid` subscribes to + * those channels and the group, so every sample has someone to find. Presence is eventually consistent, so the + * fixture polls `hereNow` for up to 45 seconds, and the clients use a short presence timeout (`world.presenceOptions`). + * + * What it proves: setState, getState, hereNow (state, occupancy only, channel group), whereNow and leave. Event samples + * (`eventListenerBasicUsage`) live in the subscribe module, which the presence page embeds too. It does not prove + * timeout and interval events. + */ +export default page('api-reference/presence', 'P4', (world) => { + // The one client that plays user `uuid`. Set by presenceFixture, used by every step below. + let occupant: PubNub | undefined; + const user = (): PubNub => { + assert.ok(occupant, 'presenceFixture has not run'); + return occupant; + }; + const sorted = (values: string[]): string[] => [...values].sort(); + + // Builds the world the samples describe: two groups share one member channel, `cg1` is subscribed by the occupant and + // `my_channel_group` is only read. The final `eventually` waits until presence has registered the occupant on all + // three channels, because every later step reads presence and would otherwise race the join. + check('presenceFixture', { needs: ['preflightPresence'], timeout: 120_000, proves: 'Fixture: an occupant is visible on all three channels, two channels sharing one group member.' }, async () => { + const [ch1, myChannel, member] = [world.n('ch1'), world.n('my_channel'), world.n('cg1_member')]; + const [cg1, otherGroup] = [world.n('cg1'), world.n('my_channel_group')]; + // Two groups share one member channel: `cg1` is subscribed by the occupant, `my_channel_group` is only read. + for (const channelGroup of [cg1, otherGroup]) { + await world.friend.channelGroups.addChannels({ channelGroup, channels: [member] }); + } + occupant = world.client('open', 'uuid', world.presenceOptions); + const set = occupant.subscriptionSet({ channels: [ch1, myChannel], channelGroups: [cg1] }); + const ready = connected(occupant, { channels: [ch1, myChannel], channelGroups: [cg1] }); + set.subscribe(); + await ready; + await occupant.setState({ state: { status: 'online' }, channels: [myChannel] }); + await eventually(async () => { + const here = await world.observer.hereNow({ channels: [ch1, myChannel, member] }); + for (const channel of [ch1, myChannel, member]) { + assert.ok(here.channels[channel]?.occupants.some((o) => o.uuid === occupant!.getUserId()), `${channel} lists the occupant`); + } + }, { timeout: PRESENCE_TIMEOUT, label: 'occupant visible in hereNow' }); + // The samples read presence through the groups too: `hereNowBasicUsage` asks for `ch1` plus `cg1` in one call, + // `hereNowChannelGroup` asks for `my_channel_group`. Both groups were created a moment ago. A group read resolves the group's + // member channels first, and that can lag the channel reads above (seen in CI, where the steps run seconds apart). So wait + // for each group on its own, and for the exact combination the basic sample uses. + const reads: Array<[string, { channels?: string[]; channelGroups: string[] }]> = [ + [cg1, { channelGroups: [cg1] }], + [otherGroup, { channelGroups: [otherGroup] }], + [`${ch1} and ${cg1}`, { channels: [ch1], channelGroups: [cg1] }], + ]; + for (const [label, query] of reads) { + await eventually(async () => { + const here = await world.observer.hereNow(query); + assert.ok(here.channels[member]?.occupants.some((o) => o.uuid === occupant!.getUserId()), `${label} lists the occupant on ${member}`); + }, { timeout: PRESENCE_TIMEOUT, label: `occupant visible through ${label}` }); + } + // The steps below read state through hereNow and the channel list through whereNow right after the snippet, in one shot. + // Both are separate reads that can lag the occupant listing above, so wait for them here, once. + await eventually(async () => { + const here = await world.observer.hereNow({ channels: [myChannel], includeState: true }); + assert.deepEqual(here.channels[myChannel]?.occupants.find((o) => o.uuid === occupant!.getUserId())?.state, { status: 'online' }, 'hereNow lists the state on the channel'); + const where = await world.observer.whereNow({ uuid: occupant!.getUserId() }); + assert.ok([ch1, myChannel].every((c) => where.channels.includes(c)), `whereNow lists both channels (got ${where.channels.length})`); + }, { timeout: PRESENCE_TIMEOUT, label: 'state and channel list visible' }); + }); + + // Proves setState stores state that a different client can read. The poll covers the delay between write and read. + step('setStateBasicUsage', { needs: ['presenceFixture'], proves: 'setState stores state that another client can read back.' }, async () => { + const pubnub = user(); + // snippet.setStateBasicUsage + const response = await pubnub.setState({ + state: { status: 'online' }, + channels: ['ch1'], + channelGroups: ['cg1'], + }); + console.log('State set successfully:', response); + // snippet.end + assert.deepEqual(response.state, { status: 'online' }); + await eventually(async () => { + const stored = await world.observer.getState({ uuid: pubnub.getUserId(), channels: [world.n('ch1')] }); + assert.deepEqual(stored.channels[world.n('ch1')], { status: 'online' }); + }, { label: 'state visible to another client' }); + // hereNowBasicUsage reads the same state through hereNow in one shot, so wait for that read path too. + await eventually(async () => { + const here = await world.observer.hereNow({ channels: [world.n('ch1')], includeState: true }); + assert.deepEqual(here.channels[world.n('ch1')]?.occupants.find((o) => o.uuid === pubnub.getUserId())?.state, { status: 'online' }, 'hereNow lists the state on ch1'); + }, { label: 'state visible through hereNow' }); + }); + + // Proves getState returns the state set above, hence the `needs`. + step('getStateBasicUsage', { needs: ['setStateBasicUsage'], proves: 'getState returns the state set earlier.' }, async () => { + const pubnub = user(); + // snippet.getStateBasicUsage + const response = await pubnub.getState({ + uuid: 'uuid', + channels: ['ch1'], + channelGroups: ['cg1'], + }); + console.log('State retrieved successfully:', response); + // snippet.end + // With channelGroups in the request the SDK returns the state one level deeper, as response.channels.channels + // (live finding). Without groups it is response.channels[channel]. + // Docs finding: with channelGroups in the request the result is nested one level deeper. + const nested = (response.channels as unknown as { channels?: Record }).channels; + assert.deepEqual(nested?.[world.n('ch1')], { status: 'online' }); + }); + + // Proves hereNow lists the occupant with its state on a channel and the group member channel appears via the group. + step('hereNowBasicUsage', { needs: ['setStateBasicUsage'], timeout: 60_000, proves: 'hereNow lists the occupant with its state and the group member channel appears through the group.' }, async () => { + const pubnub = user(); + // snippet.hereNowBasicUsage + // Function to get presence information for a channel + const result = await pubnub.hereNow({ + channels: ['ch1'], + channelGroups: ['cg1'], + includeUUIDs: true, + includeState: true, + }); + console.log('Here Now Result:', result); + // snippet.end + const entry = result.channels[world.n('ch1')]?.occupants.find((o) => o.uuid === pubnub.getUserId()); + assert.ok(entry, 'ch1 lists the occupant'); + assert.deepEqual(entry.state, { status: 'online' }); + assert.ok(result.channels[world.n('cg1_member')], 'the channel group member is part of the result'); + }); + + // Proves includeState returns the state set in presenceFixture. + step('hereNowWithState', { needs: ['presenceFixture'], proves: 'hereNow with includeState returns the state set in the fixture.' }, async () => { + const pubnub = user(); + // snippet.hereNowWithState + const response = await pubnub.hereNow({ + channels: ['my_channel'], + includeState: true, + }); + console.log('hereNow response:', response); + // snippet.end + const entry = response.channels[world.n('my_channel')]?.occupants.find((o) => o.uuid === pubnub.getUserId()); + assert.deepEqual(entry?.state, { status: 'online' }); + }); + + // Proves includeUUIDs: false gives a count and no user list. `>= 1` because other steps may also have clients on the channel. + step('hereNowFetchOccupancyOnly', { needs: ['presenceFixture'], proves: 'hereNow with includeUUIDs false gives a count and no user list.' }, async () => { + const pubnub = user(); + // snippet.hereNowFetchOccupancyOnly + const response = await pubnub.hereNow({ + channels: ['my_channel'], + includeUUIDs: false, + }); + console.log('hereNow response:', response); + // snippet.end + const entry = response.channels[world.n('my_channel')]; + assert.ok(entry.occupancy >= 1, 'occupancy counts the occupant'); + assert.equal(entry.occupants?.length ?? 0, 0, 'no user ids when includeUUIDs is false'); + }); + + // Proves a group query reports presence per member channel of the group. + step('hereNowChannelGroup', { needs: ['presenceFixture'], proves: 'hereNow on a channel group reports presence for each member channel.' }, async () => { + const pubnub = user(); + // snippet.hereNowChannelGroup + const response = await pubnub.hereNow({ + channelGroups: ['my_channel_group'], + }); + console.log('hereNow response:', response); + // snippet.end + const entry = response.channels[world.n('cg1_member')]; + assert.ok(entry, 'the group member channel is in the result'); + assert.ok(entry.occupants.some((o) => o.uuid === pubnub.getUserId())); + }); + + // The snippet returns nothing, so a spy on hereNow gives the harness the promise to await and inspect. + step('basicUsageWithPromises', { needs: ['setStateBasicUsage'], proves: 'The promise form of hereNow resolves with the occupants, seen through a spy.' }, async () => { + const pubnub = user(); + // The snippet chains .then and .catch and returns nothing. The spy hands the harness the promise. + const spy = world.spy(pubnub, 'hereNow'); + // snippet.basicUsageWithPromises + pubnub + .hereNow({ + channels: ['ch1'], + channelGroups: ['cg1'], + includeUUIDs: true, + includeState: true, + }) + .then((response) => { + console.log(response); + }) + .catch((error) => { + console.log(error); + }); + // snippet.end + assert.equal(spy.results.length, 1, 'the snippet called hereNow once'); + const response = (await spy.results[0]) as PubNub.Presence.HereNowResponse; + assert.ok(response.channels[world.n('ch1')]?.occupants.some((o) => o.uuid === pubnub.getUserId())); + }); + + // Proves whereNow lists the channels the occupant is in. Filtered to the two channels this fixture owns because other fixtures may add more. + step('whereNowBasicUsage', { needs: ['presenceFixture'], proves: 'whereNow lists the channels the occupant is in.' }, async () => { + const pubnub = world.observer; + // snippet.whereNowBasicUsage + const response = await pubnub.whereNow({ + uuid: 'uuid', + }); + console.log('whereNow response:', response); + // snippet.end + assert.deepEqual( + sorted(response.channels).filter((c) => [world.n('ch1'), world.n('my_channel')].includes(c)), + sorted([world.n('ch1'), world.n('my_channel')]), + ); + }); + + // Leaving is part of presence too: the occupant unsubscribes and the channels empty out. + check('presenceTeardown', { phase: 'P9', needs: ['presenceFixture'], timeout: 90_000, proves: 'After the occupant unsubscribes the channels empty out of hereNow.' }, async () => { + const pubnub = user(); + const ch1 = world.n('ch1'); + pubnub.unsubscribeAll(); + // Other steps leave clients subscribed to ch1, so check this occupant only, not the occupancy. + await eventually(async () => { + const here = await world.observer.hereNow({ channels: [ch1] }); + assert.ok(!here.channels[ch1]?.occupants.some((o) => o.uuid === pubnub.getUserId()), 'occupant is still listed'); + }, { timeout: PRESENCE_TIMEOUT, label: 'occupant left ch1' }); + }); +}); diff --git a/docs-snippets/api-reference/publish-legacy.ts b/docs-snippets/api-reference/publish-legacy.ts new file mode 100644 index 000000000..29ad2e122 --- /dev/null +++ b/docs-snippets/api-reference/publish-legacy.ts @@ -0,0 +1,61 @@ +import assert from '../harness/assert.js'; +import PubNub from 'pubnub'; +import { check, page } from '../harness/step.js'; +import { sleep, watch } from '../harness/wait.js'; +import { rejectsValidation } from '../harness/assertions.js'; + +/** + * docs/sdks/javascript/api-reference/publish-legacy.md + * + * Keyset: open. Phase: P8 "Legacy". In the Sneaker Drop story this covers the old fire-and-forget way to announce a + * drop to Functions only. + * + * The page has no code sample, only prose about `fire()`. The snippet `fireBasicUsage` lives in `publish.ts` (P8, kept + * with the other publish samples so the id stays together). These checks have no snippet markers, they prove the + * page's claims: `fire()` resolves with `undefined` and swallows request errors, and the replacement + * `publish({ storeInHistory: false, replicate: false })` returns a timetoken, reports errors and neither reaches + * subscribers nor Message Persistence. + */ +export default page('api-reference/publish-legacy', 'P8', (world) => { + // Records a docs/SDK mismatch: the TypeScript type promises a PublishResponse but fire() resolves with undefined. + check('publishLegacyFireResolvesUndefined', { proves: 'fire() resolves with undefined although the TypeScript type promises a response.' }, async () => { + const channel = world.n('my_channel'); + const response = await world.shopper.fire({ channel, message: { legacy: 'fire' } }); + // The TypeScript signature promises a PublishResponse, the runtime value is undefined. + assert.equal(response, undefined); + }); + + // Same client twice: publish() rejects with a validation error, fire() swallows it. A client without publishKey is the cheapest reproducible error. + check('publishLegacyFireDiscardsErrors', { proves: 'publish rejects with a validation error on a bad client while fire swallows it.' }, async () => { + // A client without publishKey cannot publish. publish() reports it, fire() does not. + const noPublishKey = world.track( + new PubNub({ subscribeKey: world.env.open!.subscribeKey, userId: world.n('fire-no-key') }), + ); + const channel = world.n('my_channel'); + await rejectsValidation(noPublishKey.publish({ channel, message: { legacy: 'publish' } }), /Missing 'publishKey'/); + assert.equal(await noPublishKey.fire({ channel, message: { legacy: 'fire' } }), undefined, 'fire() resolves undefined and discards the error'); + }); + + // Proves the replacement call. The two negative claims (no delivery, no storage) cannot wait for an event, + // so the check sleeps 4 seconds and then reads history. Needs persistence so the empty history means "not stored" + // and not "persistence is off". + check('publishLegacyReplacementDoesNotReplicateOrStore', { needs: ['preflightPersistence'], timeout: 45_000, proves: 'The fire replacement call is neither delivered to subscribers nor stored in history.' }, async () => { + const channel = world.n('fire_replacement'); + const listening = await watch(world.fresh('observer'), [channel]); + const response = await world.shopper.publish({ + channel, + message: { legacy: 'not-replicated' }, + storeInHistory: false, + replicate: false, + }); + world.check.timetoken(response.timetoken); + await sleep(4000); + assert.ok( + !listening.events.some((e) => (e.event as { message?: { legacy?: string } }).message?.legacy === 'not-replicated'), + 'with replicate: false the message does not reach subscribers', + ); + const history = await world.observer.fetchMessages({ channels: [channel], count: 25 }); + assert.equal(history.channels[channel]?.length ?? 0, 0, 'with storeInHistory: false nothing is stored'); + listening.stop(); + }); +}); diff --git a/docs-snippets/api-reference/publish.ts b/docs-snippets/api-reference/publish.ts new file mode 100644 index 000000000..553b71f4c --- /dev/null +++ b/docs-snippets/api-reference/publish.ts @@ -0,0 +1,274 @@ +import assert from '../harness/assert.js'; +import type PubNub from 'pubnub'; +import { page, step } from '../harness/step.js'; +import { recorder } from '../harness/recorder.js'; +import { eventually, sleep, watch } from '../harness/wait.js'; +import { rejectsValidation, rejectsWithCategory } from '../harness/assertions.js'; + +/** + * docs/sdks/javascript/api-reference/publish.md (plus `fire`, which has no sample on the docs page yet). + * + * Keyset: open for all steps but `publishHandleErrors` (secured). Phase: P4, `fireBasicUsage` runs in P8. In the Sneaker Drop + * story this is the shopper announcing and signalling in a drop channel. + * + * Every step subscribes a fresh observer before the snippet runs, so the effect is checked by a second client + * (design 4.4, layer 3) and the later `listening.next` cannot race the publish. Steps that store messages also read + * Message Persistence back, hence `preflightPersistence`. `publishHandleErrors` is the one snippet that keeps try, catch + * and console.error, because showing them is its purpose. `harness/lint-markers.ts` lists it in + * ERROR_HANDLING_SNIPPETS, every other snippet lets errors throw. + * What it does not prove: publish limits (message size, rate) and encrypted publish (see encryption.ts). + */ +export default page('api-reference/publish', 'P4', (world) => { + // Proves publish delivers to a subscriber with the options the snippet sets (meta, customMessageType) and stores the message. + step('publishBasicUsage', { needs: ['preflightPersistence'], proves: 'publish delivers a message with meta and a custom message type to a subscriber and stores it.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const listening = await watch(world.fresh('observer'), [channel]); + + // snippet.publishBasicUsage + const response = await pubnub.publish({ + message: { text: 'Hello World' }, + channel: 'my_channel', + sendByPost: false, + storeInHistory: true, + meta: { sender: 'user123' }, + customMessageType: 'text-message', + }); + console.log('Publish Success:', response); + // snippet.end + + world.check.timetoken(response.timetoken); + const received = await listening.next('message', (m) => (m.message as { text?: string }).text === 'Hello World'); + assert.equal(received.timetoken, response.timetoken); + assert.equal(received.publisher, pubnub.getUserId()); + assert.equal(received.userMetadata?.sender, 'user123'); + assert.equal(received.customMessageType, 'text-message'); + // storeInHistory: true, so Message Persistence has it too. + await eventually(async () => { + const history = await world.observer.fetchMessages({ channels: [channel], count: 10 }); + assert.ok(history.channels[channel]?.some((m) => m.timetoken === response.timetoken), 'message is stored'); + }, { label: 'published message is persisted' }); + listening.stop(); + }); + + // Proves the ttl variant still delivers and stores. The 10 hour expiry itself cannot be observed in a run. + step('publishStoreThePublishedMessagefor10Hours', { needs: ['preflightPersistence'], proves: 'publish with a ttl still delivers and stores the message.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const listening = await watch(world.fresh('observer'), [channel]); + + // snippet.publishStoreThePublishedMessagefor10Hours + const response = await pubnub.publish({ + message: 'hello!', + channel: 'my_channel', + storeInHistory: true, + ttl: 10, + customMessageType: 'text-message', + }); + + console.log('message published with server response:', response); + // snippet.end + + world.check.timetoken(response.timetoken); + const received = await listening.next('message', (m) => m.message === 'hello!'); + assert.equal(received.timetoken, response.timetoken); + await eventually(async () => { + const history = await world.observer.fetchMessages({ channels: [channel], count: 10 }); + assert.ok(history.channels[channel]?.some((m) => m.timetoken === response.timetoken), 'message is stored'); + }, { label: 'message with ttl is persisted' }); + listening.stop(); + }); + + // Proves the minimal call returns a string timetoken and delivers. + step('publishMinimal', { proves: 'The minimal publish returns a string timetoken and delivers the message.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const listening = await watch(world.fresh('observer'), [channel]); + + // snippet.publishMinimal + const { timetoken } = await pubnub.publish({ + channel: 'my_channel', + message: { text: 'Hello' }, + }); + + console.log(timetoken); // "17918851187695440" (a string) + // snippet.end + + assert.equal(typeof timetoken, 'string'); + world.check.timetoken(timetoken); + const received = await listening.next('message', (m) => (m.message as { text?: string }).text === 'Hello'); + assert.equal(received.timetoken, timetoken); + listening.stop(); + }); + + // Proves the wrapped value 0 is delivered, and records the claim in the snippet comment: publishing a bare 0 is rejected. + step('publishFalsyMessage', { proves: 'A wrapped value 0 is delivered as a message.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const listening = await watch(world.fresh('observer'), [channel]); + + // snippet.publishFalsyMessage + // message: 0 is rejected with "Missing 'message'", so wrap the value + await pubnub.publish({ + channel: 'my_channel', + message: { value: 0 }, + }); + // snippet.end + + const received = await listening.next('message', (m) => (m.message as { value?: number }).value === 0); + assert.deepEqual(received.message, { value: 0 }); + // The comment in the snippet is a claim about the SDK: the bare falsy value is rejected. + await rejectsValidation(pubnub.publish({ channel, message: 0 }), /Missing 'message'/); + listening.stop(); + }); + + // Proves signal reaches a subscriber as a `signal` event (not a message) with the custom message type. + step('signalBasicUsage', { proves: 'signal reaches a subscriber as a signal event with the custom message type, not as a message.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('foo'); + const listening = await watch(world.fresh('observer'), [channel]); + + // snippet.signalBasicUsage + const response = await pubnub.signal({ + message: 'hello', + channel: 'foo', + customMessageType: 'text-message', + }); + console.log('signal response:', response); + // snippet.end + + world.check.timetoken(response.timetoken); + const received = await listening.next('signal', (s) => s.message === 'hello'); + assert.equal(received.timetoken, response.timetoken); + assert.equal(received.publisher, pubnub.getUserId()); + assert.equal(received.customMessageType, 'text-message'); + listening.stop(); + }); + + // `fire` belongs to publish-legacy in the docs. The snippet id is kept so the page can embed it later. + // fire() reaches Functions only. The old snippet read `response.timetoken`, but fire() resolves with undefined (found by the first live run). + // This body drops that line on purpose, an intended difference from the old docs body. + step('fireBasicUsage', { phase: 'P8', proves: 'fire sends to Functions only, so subscribers receive nothing and history stores nothing.' }, async () => { + const pubnub = world.shopper; + const channel = world.n('my_channel'); + const listening = await watch(world.fresh('observer'), [channel]); + + // snippet.fireBasicUsage + // Initialize PubNub with your keys + await pubnub.fire({ + message: { + such: 'object', + }, + channel: 'my_channel', + sendByPost: false, // true to send via post + meta: { + cool: 'meta', + }, // fire extra meta with the request + }); + + console.log('message fired'); + // snippet.end + + // fire() sends to Functions only: subscribers do not receive it and Message Persistence does not store it. + await sleep(4000); + assert.ok( + !listening.events.some((e) => (e.event as { message?: { such?: string } }).message?.such === 'object'), + 'a fired message must not reach subscribers', + ); + const history = await world.observer.fetchMessages({ channels: [channel], count: 25 }); + assert.ok( + !history.channels[channel]?.some((m) => (m.message as { such?: string } | undefined)?.such === 'object'), + 'a fired message is not stored', + ); + listening.stop(); + }); + + // The error handling sample of the publish page. It is the one snippet with try, catch and console.error on purpose. + // The sample publishes a valid message, so the branches can only be reached by changing the client it runs with: + // success the server client publishes, nothing is logged + // access denied a client of the secured keyset without a token, the keyset enforces Access Manager (403) + // validation a client without a publish key, the SDK refuses before it sends anything + // network a client behind the local proxy while the proxy refuses tunnels + // The same snippet text runs four times through `attempt`, and the step asserts which line it logged each time. + // + // Precondition (added after a live failure, see the wait below): the channel must really refuse a client without a token. + // access-manager-v2.ts `grantChannelAllAuthKeys` (P1) opens the very same run-suffixed channel for every client, and it + // revokes right after its own check, but an Access Manager (v2) revoke takes up to about 60 seconds to propagate. + step('publishHandleErrors', { keyset: 'pam', timeout: 240_000, proves: 'The same error handling code logs the right line for success, access denied (403), validation and network failure.' }, async () => { + // The docs use the class name `PubNubError` without an import line, so give the snippet that name. + type PubNubError = PubNub.PubNubError; + + const attempt = async (pubnub: PubNub): Promise => { + const errors = world.captureConsole('error'); + try { + // snippet.publishHandleErrors + try { + await pubnub.publish({ + channel: 'my_channel', + message: { text: 'Hello' }, + }); + } catch (e) { + const status = (e as PubNubError).status; + + if (status?.category === 'PNValidationErrorCategory') { + console.error('The call is invalid:', status.message); + } else if (status?.category === 'PNAccessDeniedCategory') { + console.error('403: the token or authKey has no write permission on the channel'); + } else if (status?.category === 'PNNetworkIssuesCategory') { + console.error('The network is unreachable. Retry the publish yourself.'); + } else { + console.error(status?.category, status?.statusCode, status?.errorData); + } + } + // snippet.end + } finally { + errors.restore(); + } + return errors.lines; + }; + + // Wait until the keyset enforces Access Manager on this channel for a stranger. Without this wait the 403 branch below + // is only as reliable as the propagation of earlier grants and revokes on the keyset. A keyset that never denies fails + // here with a message that names the cause, not with an empty list further down. + await eventually( + () => + rejectsWithCategory( + world.client('secured', 'no-token-probe').publish({ channel: world.n('my_channel'), message: { probe: true } }), + 'PNAccessDeniedCategory', + ), + { timeout: 120_000, interval: 3_000, label: 'a client without a token is refused on the channel (Access Manager grants and revokes take up to 60 s to propagate)' }, + ); + + // Success: the server client may publish, so no error line. + assert.deepEqual(await attempt(world.server), [], 'a valid publish logs nothing'); + + // Access denied: the secured keyset enforces Access Manager and this client has no token. + // A grant or revoke reaches the service nodes one after the other. Live run 3xuhpx saw the probe above refused and the very next + // tokenless publish accepted (nothing logged), so one refusal does not prove every node refuses. The step claim is what the + // code logs when the 403 arrives, so repeat the attempt until the 403 line appears. The number of attempts goes into the + // report, so an enforcement that stays uneven shows up as data. A client that is never refused fails after the wait. + let deniedAttempts = 0; + await eventually(async () => { + deniedAttempts += 1; + const denied = await attempt(world.client('secured', `no-token-user-${deniedAttempts}`)); + assert.deepEqual(denied, ['403: the token or authKey has no write permission on the channel']); + }, { timeout: 90_000, interval: 3_000, label: 'a tokenless publish logs the 403 line (Access Manager enforcement reaches every node)' }); + recorder.record({ kind: 'event', label: `tokenless publish logged the 403 line on attempt ${deniedAttempts}`, ok: true }); + + // Validation: no publish key, the SDK refuses before it sends. + const invalid = await attempt(world.client('secured', 'no-publish-key-user', { publishKey: '' })); + assert.equal(invalid.length, 1); + assert.match(invalid[0], /^The call is invalid: /); + + // Network: the proxy refuses every tunnel. Publish has no retry, so the call fails at once. + await world.proxy.start(); + const proxied = world.client('secured', 'proxied-user'); + proxied.setProxy(world.proxy.config); + world.proxy.outage(true); + try { + assert.deepEqual(await attempt(proxied), ['The network is unreachable. Retry the publish yourself.']); + } finally { + world.proxy.outage(false); + } + }); +}); diff --git a/docs-snippets/api-reference/subscribe-legacy.ts b/docs-snippets/api-reference/subscribe-legacy.ts new file mode 100644 index 000000000..a0f66dfb4 --- /dev/null +++ b/docs-snippets/api-reference/subscribe-legacy.ts @@ -0,0 +1,579 @@ +import assert from '../harness/assert.js'; +import { page, step, check } from '../harness/step.js'; +import { recorder } from '../harness/recorder.js'; +import { connected, eventually, expectEvent, expectStatus, KindLog, PRESENCE_EVENT_TIMEOUT, sleep } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/api-reference/subscribe-legacy.md + * + * Keyset: open. Phase: P8 "Legacy", the group fixture runs in P3. `pubnub.subscribe()`, `pubnub.unsubscribe()` and + * `pubnub.unsubscribeAll()` are the older way to call Subscribe. In the Sneaker Drop story this is the original shop + * app before it moved to subscription objects. + * + * Every step runs on its own throwaway client (`world.fresh`) so it cannot disturb the subscriptions of the P4 steps, + * and a second client (`friend`) publishes. The docs show the `OLD*` snippet ids, which this module keeps. Two ids are + * new because the docs page still hardcodes those blocks: `OLDaddListeners` and `OLDremoveListeners`. + * Waits follow the pattern `connected()` and `expectEvent()` registered before the snippet, then the publish. + * What it proves: subscribe forms, listeners, unsubscribe forms. Two checks record where the docs and the SDK disagree + * (status fields, see below). + */ +export default page('api-reference/subscribe-legacy', 'P8', (world) => { + // Every message the friend publishes carries `{ legacy: }`, so a step can recognise its own message. + const tag = (m: { message: unknown }): string | undefined => (m.message as { legacy?: string }).legacy; + // Subscription state lists have no guaranteed order. + const sorted = (values: string[]): string[] => [...values].sort(); + + /** The groups the samples subscribe to, each with one member channel. The groups must exist and hold a channel to carry messages. */ + // The groups the samples subscribe to, each with one member channel. Groups must hold a channel to carry messages. + const GROUPS = ['my_channelGroup', 'family', 'my_channelGroup1', 'my_channelGroup2', 'my_channelGroup3', 'demo_group1', 'demo_group2']; + const member = (group: string): string => `${world.n(group)}_member`; + + // Creates every group above with its member channel. Several groups share names with channel-groups.ts (`family`), which cleans up after itself. + check('legacyGroupsFixture', { phase: 'P3', proves: 'Fixture: every channel group of the legacy subscribe page exists with a member channel.' }, async () => { + for (const group of GROUPS) { + await world.friend.channelGroups.addChannels({ channelGroup: world.n(group), channels: [member(group)] }); + } + await eventually(async () => { + for (const group of GROUPS) { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: world.n(group) }); + assert.deepEqual(listed.channels, [member(group)], `${group} lists its member`); + } + }, { label: 'legacy groups have their members' }); + }); + + // Proves pubnub.subscribe({ channels }) connects and delivers. + step('OLDsubscribeBasicUsage', { proves: 'The legacy subscribe call connects and delivers a message.' }, async () => { + const pubnub = world.fresh('oldsub'); + const channel = world.n('my_channel'); + const ready = connected(pubnub, { channels: [channel] }); + const received = expectEvent(pubnub, 'message', (m) => tag(m) === 'basic' && m.channel === channel); + + // snippet.OLDsubscribeBasicUsage + pubnub.subscribe({ + channels: ['my_channel'], + }); + // snippet.end + + await ready; + assert.deepEqual(pubnub.getSubscribedChannels(), [channel]); + await world.friend.publish({ channel, message: { legacy: 'basic' } }); + await received; + pubnub.unsubscribeAll(); + }); + + // Proves one subscribe call covers three channels, checked by a message on the middle one. + step('OLDsubscribeMultipleChannels', { proves: 'One legacy subscribe call covers three channels, shown by a message on the middle one.' }, async () => { + const pubnub = world.fresh('oldmulti'); + const channels = ['my_channel_1', 'my_channel_2', 'my_channel_3'].map((c) => world.n(c)); + const ready = connected(pubnub, { channels }); + const received = expectEvent(pubnub, 'message', (m) => tag(m) === 'multi' && m.channel === channels[1]); + + // snippet.OLDsubscribeMultipleChannels + pubnub.subscribe({ + channels: ['my_channel_1', 'my_channel_2', 'my_channel_3'], + }); + // snippet.end + + await ready; + assert.deepEqual(sorted(pubnub.getSubscribedChannels()), sorted(channels)); + await world.friend.publish({ channel: channels[1], message: { legacy: 'multi' } }); + await received; + pubnub.unsubscribeAll(); + }); + + // Proves withPresence: true yields the client's own join event with the fields the docs table lists. + step('OLDsubscribeWithPresence', { needs: ['preflightPresence'], proves: 'withPresence true produces the own join event with the fields the page table lists.' }, async () => { + const pubnub = world.fresh('oldpresence'); + const channel = world.n('my_channel'); + const ready = connected(pubnub, { channels: [channel] }); + const joined = expectEvent(pubnub, 'presence', (p) => p.action === 'join' && p.channel === channel && p.uuid === pubnub.getUserId()); + + // snippet.OLDsubscribeWithPresence + pubnub.subscribe({ + channels: ['my_channel'], + withPresence: true, + }); + // snippet.end + + await ready; + const event = await joined; + // The docs presence payload table promises these fields. + assert.equal(typeof (event as { occupancy?: number }).occupancy, 'number'); + assert.ok(event.timetoken); + pubnub.unsubscribeAll(); + }); + + // Proves a wildcard subscription receives a message on a matching channel, reported as the subscription. + step('OLDsubscribeWithWildCardChannels', { proves: 'A wildcard subscription receives a message on a matching channel.' }, async () => { + const pubnub = world.fresh('oldwild'); + const root = world.n('ab.*'); + const actual = root.replace('.*', '.news'); + const ready = connected(pubnub, { channels: [root] }); + const received = expectEvent(pubnub, 'message', (m) => tag(m) === 'wild' && m.channel === actual); + + // snippet.OLDsubscribeWithWildCardChannels + pubnub.subscribe({ + channels: ['ab.*'], + }); + // snippet.end + + await ready; + await world.friend.publish({ channel: actual, message: { legacy: 'wild' } }); + const message = await received; + // The wildcard match is reported as the subscription. + assert.equal(message.subscription, root); + pubnub.unsubscribeAll(); + }); + + // Proves the connected-status handler can set state. The observer reads the state back per channel, because with several + // channels getState nests its result one level deeper (docs finding). + step('OLDsubscribeWithState', { needs: ['preflightPresence'], timeout: 60_000, proves: 'The connected status handler can set state, and the observer reads it back per channel.' }, async () => { + const pubnub = world.fresh('oldstate'); + const channels = ['ch1', 'ch2', 'ch3'].map((c) => world.n(c)); + const ready = connected(pubnub, { channels }); + + // snippet.OLDsubscribeWithState + pubnub.addListener({ + status: async (statusEvent) => { + if (statusEvent.category === 'PNConnectedCategory') { + await pubnub.setState({ + state: { + some: 'state', + }, + channels: ['ch1', 'ch2', 'ch3'], + }); + } + }, + message: (messageEvent) => { + // handle message + }, + presence: (presenceEvent) => { + // handle presence + }, + }); + + pubnub.subscribe({ + channels: ['ch1', 'ch2', 'ch3'], + }); + // snippet.end + + await ready; + // The observer reads the state back. The docs sample called setState without channels, which the SDK rejects (an intended difference from the old docs body). + await eventually(async () => { + // One channel per call: with several channels getState nests the result one level deeper (docs finding). + for (const channel of channels) { + const state = await world.observer.getState({ uuid: pubnub.getUserId(), channels: [channel] }); + assert.deepEqual(state.channels[channel], { some: 'state' }, `state on ${channel}`); + } + }, { timeout: 30_000, label: 'state set from the connected status handler' }); + pubnub.unsubscribeAll(); + }); + + // Proves subscribing to a group delivers a message from its member channel, reported with the group as subscription. + step('OLDsubscribeChannelGroup', { needs: ['legacyGroupsFixture'], proves: 'Subscribing to a channel group delivers a message from its member channel.' }, async () => { + const pubnub = world.fresh('oldgroup'); + const group = world.n('my_channelGroup'); + const ready = connected(pubnub, { channelGroups: [group] }); + const received = expectEvent(pubnub, 'message', (m) => tag(m) === 'group' && m.subscription === group); + + // snippet.OLDsubscribeChannelGroup + pubnub.subscribe({ + channelGroups: ['my_channelGroup'], + }); + // snippet.end + + await ready; + assert.deepEqual(pubnub.getSubscribedChannelGroups(), [group]); + await world.friend.publish({ channel: member('my_channelGroup'), message: { legacy: 'group' } }); + const message = await received; + assert.equal(message.channel, member('my_channelGroup')); + pubnub.unsubscribeAll(); + }); + + // Proves a group subscription with presence yields a join event that belongs to the group. + step('OLDsubscribeChannelGroupWithPresence', { needs: ['legacyGroupsFixture', 'preflightPresence'], timeout: 60_000, proves: 'A group subscription with presence yields a join event that belongs to the group.' }, async () => { + const pubnub = world.fresh('oldgrouppresence'); + const group = world.n('family'); + const ready = connected(pubnub, { channelGroups: [group] }); + const joined = expectEvent(pubnub, 'presence', (p) => p.action === 'join' && p.uuid === pubnub.getUserId()); + + // snippet.OLDsubscribeChannelGroupWithPresence + pubnub.subscribe({ + channelGroups: ['family'], + withPresence: true, + }); + // snippet.end + + await ready; + const event = await joined; + assert.ok(event.channel === member('family') || event.subscription === group, `presence event belongs to the group (channel ${event.channel}, subscription ${event.subscription})`); + pubnub.unsubscribeAll(); + }); + + // Proves one call subscribes to three groups, checked by a message through the last group. + step('OLDsubscribeMultipleChannelGroup', { needs: ['legacyGroupsFixture'], proves: 'One call subscribes to three groups, shown by a message through the last group.' }, async () => { + const pubnub = world.fresh('oldgroups'); + const groups = ['my_channelGroup1', 'my_channelGroup2', 'my_channelGroup3'].map((g) => world.n(g)); + const ready = connected(pubnub, { channelGroups: groups }); + const received = expectEvent(pubnub, 'message', (m) => tag(m) === 'groups' && m.subscription === groups[2]); + + // snippet.OLDsubscribeMultipleChannelGroup + pubnub.subscribe({ + channelGroups: ['my_channelGroup1', 'my_channelGroup2', 'my_channelGroup3'], + }); + // snippet.end + + await ready; + assert.deepEqual(sorted(pubnub.getSubscribedChannelGroups()), sorted(groups)); + await world.friend.publish({ channel: member('my_channelGroup3'), message: { legacy: 'groups' } }); + await received; + pubnub.unsubscribeAll(); + }); + + // Proves channels and groups can be mixed in one call, with one message through each. + step('OLDsubscribeChannelGroupAndChannels', { needs: ['legacyGroupsFixture'], proves: 'Channels and groups can be mixed in one call, shown by a message through each.' }, async () => { + const pubnub = world.fresh('oldmixed'); + const [channel, group] = [world.n('my_channel'), world.n('my_channelGroup')]; + const ready = connected(pubnub, { channels: [channel], channelGroups: [group] }); + const onChannel = expectEvent(pubnub, 'message', (m) => tag(m) === 'mixed-channel' && m.channel === channel); + const onGroup = expectEvent(pubnub, 'message', (m) => tag(m) === 'mixed-group' && m.subscription === group); + + // snippet.OLDsubscribeChannelGroupAndChannels + pubnub.subscribe({ + channels: ['my_channel'], + channelGroups: ['my_channelGroup'], + }); + // snippet.end + + await ready; + await world.friend.publish({ channel, message: { legacy: 'mixed-channel' } }); + await world.friend.publish({ channel: member('my_channelGroup'), message: { legacy: 'mixed-group' } }); + await onChannel; + await onGroup; + pubnub.unsubscribeAll(); + }); + + // The listener samples. The docs page hardcodes them, so these two ids are proposed ids, the docs page does not embed them yet. + // Why the step is built this way (an intermittent 30 s timeout on a presence event): + // - The snippet names no channel, so the step uses its own channel (`legacy_listeners_channel`) and not the shared `my_channel`. + // On `my_channel` other steps join and leave in the same seconds, which can satisfy or starve a "some presence event arrived" wait. + // - The presence event is caused on purpose: a second fresh client joins the channel and later leaves. The wait is for exactly + // that join, so it does not depend on whether the client's own join falls before or after its first subscribe timetoken. + // The own join is still recorded in the report, but not asserted. + step('OLDaddListeners', { needs: ['preflightPresence'], timeout: 90_000, proves: 'The legacy listener sample handlers see status, message and presence events whose fields exist on live events.' }, async () => { + const pubnub = world.fresh('oldlisteners'); + const joiner = world.fresh('oldlistenersjoiner'); + const channel = world.n('legacy_listeners_channel'); + // A recording listener next to the snippet's own proves that the fields the snippet reads exist on the live events. + // KindLog keeps a count and the first arrival time per kind, so a timeout names the missing kinds. `match` narrows each + // kind to the event this step caused. + const log = new KindLog(['message', 'presence', 'signal', 'objects', 'messageAction', 'status'], { + message: (m) => (m.message as { legacy?: string }).legacy === 'listeners', + presence: (p) => p.action === 'join' && p.uuid === joiner.getUserId(), + signal: (s) => s.message === 'ping', + objects: (o) => o.message?.event === 'set' && o.message?.data?.id === channel, + messageAction: (a) => a.event === 'added', + }); + const seen = Object.fromEntries(log.kinds.map((kind) => [kind, log.of(kind)])) as Record; + pubnub.addListener({ + message: log.on('message'), + presence: log.on('presence'), + signal: log.on('signal'), + objects: log.on('objects'), + messageAction: log.on('messageAction'), + status: log.on('status'), + }); + + // snippet.OLDaddListeners + pubnub.addListener({ + // Messages + message: function (m) { + const channelName = m.channel; // Channel on which the message was published + const channelGroup = m.subscription; // Channel group or wildcard subscription match (if exists) + const pubTT = m.timetoken; // Publish timetoken + const msg = m.message; // Message payload + const publisher = m.publisher; // Message publisher + }, + // Presence + // requires a subscription with presence + presence: function (p) { + const action = p.action; // Can be join, leave, timeout, state-change, or interval + const channelName = p.channel; // Channel to which the message belongs + const channelGroup = p.subscription; // Channel group or wildcard subscription match, if any + const publishTime = p.timestamp; // Publish timetoken + const timetoken = p.timetoken; // Current timetoken + if (p.action !== 'state-change') { + const occupancy = p.occupancy; // Number of users subscribed to the channel + } + if (p.action === 'state-change') { + const state = p.state; // User state + } + if (p.action !== 'interval') { + const uuid = p.uuid; // User ID of the user who joined, left, timed out, or changed state + } + }, + // Signals + signal: function (s) { + const channelName = s.channel; // Channel to which the signal belongs + const channelGroup = s.subscription; // Channel group or wildcard subscription match, if any + const pubTT = s.timetoken; // Publish timetoken + const msg = s.message; // Payload + const publisher = s.publisher; // Message publisher + }, + // App Context + objects: (objectEvent) => { + const channel = objectEvent.channel; // Channel to which the event belongs + const channelGroup = objectEvent.subscription; // Channel group + const timetoken = objectEvent.timetoken; // Event timetoken + const event = objectEvent.message.event; // Name of the event that occurred + const type = objectEvent.message.type; // Type of the object that changed + const data = objectEvent.message.data; // Data from the event that occurred + }, + // Message Actions + messageAction: function (ma) { + const channelName = ma.channel; // Channel to which the message belongs + const publisher = ma.publisher; // Message publisher + const event = ma.event; // Message action added or removed + const type = ma.data.type; // Message action type + const value = ma.data.value; // Message action value + const messageTimetoken = ma.data.messageTimetoken; // Timetoken of the original message + const actionTimetoken = ma.data.actionTimetoken; // Timetoken of the message action + }, + // File Sharing + file: function (event) { + const channelName = event.channel; // Channel to which the file belongs + const channelGroup = event.subscription; // Channel group or wildcard subscription match (if exists) + const publisher = event.publisher; // File publisher + const timetoken = event.timetoken; // Event timetoken + + const message = event.message; // Optional message attached to the file + const fileId = event.file?.id; // File unique id + const fileName = event.file?.name; // File name + const fileUrl = event.file?.url; // File direct URL + }, + status: function (s) { + const affectedChannelGroups = s.affectedChannelGroups; // Array of channel groups affected in the operation + const affectedChannels = s.affectedChannels; // Array of channels affected in the operation + const category = s.category; // Returns PNConnectedCategory + const operation = s.operation; // Returns PNSubscribeOperation + const lastTimetoken = s.lastTimetoken; // Last timetoken used in the subscribe request (type long), only with enableEventEngine: false + const currentTimetoken = s.currentTimetoken; /* Current timetoken fetched in subscribe response, + * to be used in the next request (type long) */ + const subscribedChannels = s.subscribedChannels; // Array of all currently subscribed channels, only with enableEventEngine: false + }, + }); + // snippet.end + + const ready = connected(pubnub, { channels: [channel] }); + pubnub.subscribe({ channels: [channel], withPresence: true }); + await ready; + // The second client joins only after the first one is connected, so its join is published after the first client's + // subscribe loop is running and must reach it. + const joinerReady = connected(joiner, { channels: [channel] }); + joiner.subscribe({ channels: [channel] }); + await joinerReady; + const published = await world.friend.publish({ channel, message: { legacy: 'listeners' } }); + await world.friend.signal({ channel, message: 'ping' }); + await world.friend.addMessageAction({ channel, messageTimetoken: published.timetoken, action: { type: 'reaction', value: 'smiley_face' } }); + await world.friend.objects.setChannelMetadata({ channel, data: { name: 'legacy listeners' } }); + // On timeout the error names the kinds that arrived (with counts and times) and the ones still missing, and adds what + // the service reports for the channel, so a missing presence event can be told from a client that is not subscribed. + try { + await eventually(() => log.assertArrived(['message', 'presence', 'signal', 'objects', 'messageAction']), { timeout: PRESENCE_EVENT_TIMEOUT, label: 'every event kind arrived' }); + } catch (error) { + const here = await world.observer.hereNow({ channels: [channel] }).then((r) => `hereNow occupancy ${r.channels[channel]?.occupancy ?? 0}`, (e) => `hereNow failed: ${(e as Error).message}`); + throw new Error(`${(error as Error).message}. ${here}. All presence events seen: ${seen.presence.map((p) => `${p.action} ${p.uuid}`).join(', ') || 'none'}`); + } + // Keep the arrival times in the report: they show how late the slowest kind was. + recorder.record({ kind: 'event', label: `event kinds: ${log.summary()}`, ok: true }); + // The joiner leaves and the leave event must follow. Same wait, same reasoning. + joiner.unsubscribeAll(); + await eventually(() => assert.ok(seen.presence.some((p) => p.action === 'leave' && p.uuid === joiner.getUserId()), `the joiner's leave event arrived. Presence events seen: ${seen.presence.map((p) => `${p.action} ${p.uuid}`).join(', ')}`), { timeout: PRESENCE_EVENT_TIMEOUT, label: 'leave event arrived' }); + // The own join is not asserted: whether it is delivered depends on where the join falls against the first subscribe timetoken. + // Recording it keeps the question open in the report instead of hiding it. + recorder.record({ kind: 'event', label: `own join event ${seen.presence.some((p) => p.action === 'join' && p.uuid === pubnub.getUserId()) ? 'was' : 'was NOT'} delivered to the subscribing client (not asserted)`, ok: true }); + + // The fields the snippet reads exist on the live payloads. + const [message] = seen.message; + assert.equal(message.channel, channel); + assert.equal(message.publisher, world.friend.getUserId()); + assert.ok(message.timetoken); + const [presence] = log.matching('presence'); + assert.equal(presence.channel, channel); + assert.equal(typeof presence.occupancy, 'number'); + assert.ok(presence.timestamp, 'presence timestamp'); + assert.ok(presence.timetoken, 'presence timetoken'); + assert.equal(presence.uuid, joiner.getUserId()); + const leave = seen.presence.find((p) => p.action === 'leave' && p.uuid === joiner.getUserId())!; + assert.equal(typeof leave.occupancy, 'number'); + const [signal] = seen.signal; + assert.equal(signal.publisher, world.friend.getUserId()); + const [objectEvent] = seen.objects; + assert.equal(objectEvent.message.event, 'set'); + assert.equal(objectEvent.message.type, 'channel'); + assert.equal(objectEvent.message.data.id, channel); + // The old sample read event, type, data and publisher from the event itself. They are under message, and objects events have no publisher. + assert.equal(objectEvent.event, undefined); + assert.equal(objectEvent.publisher, undefined); + const [action] = seen.messageAction; + assert.equal(action.event, 'added'); + assert.equal(action.data.type, 'reaction'); + assert.equal(action.data.value, 'smiley_face'); + assert.equal(action.data.messageTimetoken, published.timetoken); + assert.ok(action.data.actionTimetoken); + assert.ok(action.publisher, 'message action publisher (the docs read ma.publisher)'); + const connectedStatus = seen.status.find((s) => s.category === 'PNConnectedCategory'); + assert.ok(connectedStatus, 'connected status'); + assert.equal(connectedStatus.operation, 'PNSubscribeOperation'); + // The new-subscription-loop status carries neither lastTimetoken nor subscribedChannels, see legacyStatusFieldsByEventEngine. + assert.ok(connectedStatus.affectedChannels.includes(channel)); + assert.ok(connectedStatus.currentTimetoken); + pubnub.unsubscribeAll(); + await world.friend.objects.removeChannelMetadata({ channel }); + }); + + // The sample above and the page's status table list lastTimetoken and subscribedChannels. Only the legacy manager sets them. + check('legacyStatusFieldsByEventEngine', { proves: 'lastTimetoken and subscribedChannels are set on the status only by the legacy subscription manager.' }, async () => { + const channel = world.n('my_channel'); + for (const enableEventEngine of [true, false]) { + const pubnub = world.fresh(`oldstatus-${enableEventEngine}`, { enableEventEngine }); + const statuses: Array> = []; + pubnub.addListener({ status: (s) => statuses.push(s as unknown as Record) }); + pubnub.subscribe({ channels: [channel] }); + await eventually(() => assert.ok(statuses.some((s) => s.category === 'PNConnectedCategory')), { label: 'connected' }); + const status = statuses.find((s) => s.category === 'PNConnectedCategory')!; + assert.equal(status.operation, 'PNSubscribeOperation'); + assert.deepEqual(status.affectedChannels, [channel]); + assert.ok(status.currentTimetoken, 'currentTimetoken'); + assert.equal('lastTimetoken' in status, !enableEventEngine, `lastTimetoken is present only with enableEventEngine: false (enableEventEngine ${enableEventEngine})`); + assert.equal('subscribedChannels' in status, !enableEventEngine, `subscribedChannels is present only with enableEventEngine: false (enableEventEngine ${enableEventEngine})`); + pubnub.unsubscribeAll(); + } + }); + + // The sample removes a listener object it declares itself, so the step can only observe that the call is targeted: the + // client's other listener keeps receiving and the removed object receives nothing. The effect of removing a listener that + // was added is the next check. + step('OLDremoveListeners', { timeout: 45_000, proves: 'removeListener leaves the other listeners of the client working, and the removed object receives nothing.' }, async () => { + const pubnub = world.fresh('oldremove'); + const channel = world.n('my_channel'); + const kept: unknown[] = []; + pubnub.addListener({ message: (m) => kept.push(m) }); + + // snippet.OLDremoveListeners + var existingListener = { + message: function () { + }, + }; + + pubnub.removeListener(existingListener); + // snippet.end + const removedCalls = world.spy(existingListener, 'message'); + const ready = connected(pubnub, { channels: [channel] }); + pubnub.subscribe({ channels: [channel] }); + await ready; + try { + await world.observer.publish({ channel, message: { text: 'after removeListener' } }); + await eventually(() => assert.equal(kept.length, 1, 'the other listener still receives'), { label: 'kept listener receives' }); + assert.equal(removedCalls.calls.length, 0, 'the removed listener object received nothing'); + } finally { + pubnub.unsubscribeAll(); + } + }); + + // Effect of removeListener: a removed listener gets no more events, a kept one does. + check('legacyRemoveListenerStopsDelivery', { proves: 'A removed listener gets no more events and a kept one does.' }, async () => { + const pubnub = world.fresh('oldremoveeffect'); + const channel = world.n('my_channel'); + const removed: unknown[] = []; + const kept: unknown[] = []; + const removable = { message: (m: unknown) => removed.push(m) }; + pubnub.addListener(removable); + pubnub.addListener({ message: (m) => kept.push(m) }); + const ready = connected(pubnub, { channels: [channel] }); + pubnub.subscribe({ channels: [channel] }); + await ready; + pubnub.removeListener(removable); + await world.friend.publish({ channel, message: { legacy: 'after-remove' } }); + await eventually(() => assert.equal(kept.length, 1), { label: 'kept listener got the message' }); + await sleep(500); + assert.equal(removed.length, 0, 'the removed listener got nothing'); + pubnub.unsubscribeAll(); + }); + + // Proves unsubscribe({ channels }) drops the channel. The setup subscribes first, since there is nothing to unsubscribe from otherwise. + step('OLDUnsubscribeBasicUsage', { proves: 'unsubscribe with channels drops that channel.' }, async () => { + const pubnub = world.fresh('oldunsub'); + const channel = world.n('my_channel'); + const ready = connected(pubnub, { channels: [channel] }); + pubnub.subscribe({ channels: ['my_channel'].map((c) => world.n(c)) }); + await ready; + assert.deepEqual(pubnub.getSubscribedChannels(), [channel]); + + // snippet.OLDUnsubscribeBasicUsage + pubnub.unsubscribe({ + channels: ['my_channel'], + }); + // snippet.end + + await eventually(() => assert.deepEqual(pubnub.getSubscribedChannels(), []), { label: 'channel dropped' }); + }); + + // Proves unsubscribing three of four channels leaves exactly the fourth. + step('OLDUnsubscribeMultipleChannels', { proves: 'Unsubscribing three of four channels leaves exactly the fourth.' }, async () => { + const pubnub = world.fresh('oldunsubmulti'); + const channels = ['chan1', 'chan2', 'chan3', 'ch1'].map((c) => world.n(c)); + const ready = connected(pubnub, { channels }); + pubnub.subscribe({ channels }); + await ready; + + // snippet.OLDUnsubscribeMultipleChannels + pubnub.unsubscribe({ + channels: ['chan1', 'chan2', 'chan3'], + }); + // snippet.end + + // Only the channel that the sample did not name stays subscribed. + await eventually(() => assert.deepEqual(pubnub.getSubscribedChannels(), [world.n('ch1')]), { label: 'three channels dropped' }); + pubnub.unsubscribeAll(); + }); + + // Proves unsubscribing two of three groups leaves exactly the third. + step('OLDUnsubscribeMultipleChannelGroup', { needs: ['legacyGroupsFixture'], proves: 'Unsubscribing two of three groups leaves exactly the third.' }, async () => { + const pubnub = world.fresh('oldunsubgroups'); + const groups = ['demo_group1', 'demo_group2', 'family'].map((g) => world.n(g)); + const ready = connected(pubnub, { channelGroups: groups }); + pubnub.subscribe({ channelGroups: groups }); + await ready; + + // snippet.OLDUnsubscribeMultipleChannelGroup + pubnub.unsubscribe({ + channelGroups: ['demo_group1', 'demo_group2'], + }); + // snippet.end + + await eventually(() => assert.deepEqual(pubnub.getSubscribedChannelGroups(), [world.n('family')]), { label: 'two groups dropped' }); + pubnub.unsubscribeAll(); + }); + + // Proves unsubscribeAll clears channels and groups. The disconnected status promise is only there so a rejection after the run cannot surface as an unhandled rejection. + step('OLDUnsubscribeAllBasicUsage', { needs: ['legacyGroupsFixture'], proves: 'unsubscribeAll clears all channels and groups.' }, async () => { + const pubnub = world.fresh('oldunsuball'); + const channels = ['ch1', 'ch2'].map((c) => world.n(c)); + const group = world.n('family'); + const ready = connected(pubnub, { channels, channelGroups: [group] }); + pubnub.subscribe({ channels, channelGroups: [group] }); + await ready; + const disconnected = expectStatus(pubnub, ['PNDisconnectedCategory']); + + // snippet.OLDUnsubscribeAllBasicUsage + pubnub.unsubscribeAll(); + // snippet.end + + await eventually(() => { + assert.deepEqual(pubnub.getSubscribedChannels(), []); + assert.deepEqual(pubnub.getSubscribedChannelGroups(), []); + }, { label: 'everything dropped' }); + void disconnected.catch(() => undefined); + }); +}); diff --git a/docs-snippets/api-reference/subscribe.ts b/docs-snippets/api-reference/subscribe.ts new file mode 100644 index 000000000..4a764a073 --- /dev/null +++ b/docs-snippets/api-reference/subscribe.ts @@ -0,0 +1,454 @@ +import assert from '../harness/assert.js'; +import { page, step, check } from '../harness/step.js'; +import { assertAll, connected, eventually, expectEvent, PRESENCE_EVENT_TIMEOUT } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/api-reference/subscribe.md, without the DataSync samples (they belong to the DataSync + * module, api-reference/datasync.ts). + * + * Keyset: open. Phase: P4, the group fixture runs in P3. In the Sneaker Drop story this is the shop app subscribing to + * drop channels. Snippets that subscribe run on a fresh client so they cannot disturb each other. A second client + * (`friend`) publishes, and the snippet's own listeners are proven by the console lines they write + * (`world.withConsole`), so the snippet text needs no change. + * Waits: `connected()` is registered BEFORE the snippet subscribes because statuses are not replayed, and + * `expectEvent()` is registered before the publish for the same reason. + * What it does not prove: reconnection, presence heartbeat timing and filter expressions. + */ +export default page('api-reference/subscribe', 'P4', (world) => { + // Subscription state lists have no guaranteed order. + const sorted = (values: string[]): string[] => [...values].sort(); + // True when a captured console line starts with `prefix`. Used to prove a snippet listener ran. + const hasLine = (lines: string[], prefix: string): boolean => lines.some((line) => line.startsWith(prefix)); + + // Gives `channelGroup_1` one member channel, because three samples subscribe to it. A group without channels carries no messages. + // Channel group `channelGroup_1` is subscribed by three samples. Give it one member channel. + check('subscribeGroupFixture', { phase: 'P3', proves: 'Fixture: channelGroup_1 has a member channel so that group subscriptions carry messages.' }, async () => { + const group = world.n('channelGroup_1'); + const member = world.n('channelGroup_1_member'); + await world.friend.channelGroups.addChannels({ channelGroup: group, channels: [member] }); + await eventually(async () => { + const listed = await world.observer.channelGroups.listChannels({ channelGroup: group }); + assert.deepEqual(listed.channels, [member]); + }, { label: 'channelGroup_1 has its member' }); + }); + + // Proves the entity factory returns the suffixed name and does not subscribe. + step('createChannelBasicUsage', { proves: 'The channel entity factory returns the run channel name and does not subscribe.' }, () => { + const pubnub = world.shopper; + // snippet.createChannelBasicUsage + const channel = pubnub.channel('my_channel'); + // snippet.end + assert.equal(channel.name, world.n('my_channel')); + assert.deepEqual(pubnub.getSubscribedChannels(), [], 'creating an entity does not subscribe'); + }); + + // Proves the channel group entity carries the (staged) group name. + step('createChannelGroupBasicUsage', { proves: 'The channel group entity carries the run group name.' }, () => { + const pubnub = world.shopper; + // snippet.createChannelGroupBasicUsage + // Initialize PubNub with demo keys + const channelGroup = pubnub.channelGroup('channelGroup_1'); + // snippet.end + assert.equal(channelGroup.name, world.n('channelGroup_1')); + }); + + // Proves the channel metadata entity carries the staged id. No network call happens. + step('createChannelMetadataBasicUsage', { proves: 'The channel metadata entity carries the run id and makes no network call.' }, () => { + const pubnub = world.shopper; + // snippet.createChannelMetadataBasicUsage + // Initialize PubNub with demo keys + + const channelMetadata = pubnub.channelMetadata('channel_1'); + // snippet.end + assert.equal(channelMetadata.id, world.n('channel_1')); + }); + + // Proves the user metadata entity carries the staged id. No network call happens. + step('createUserMetadataBasicUsage', { proves: 'The user metadata entity carries the run id and makes no network call.' }, () => { + const pubnub = world.shopper; + // snippet.createUserMetadataBasicUsage + // Initialize PubNub with demo keys + + const userMetadata = pubnub.userMetadata('user_meta1'); + // snippet.end + assert.equal(userMetadata.id, world.n('user_meta1')); + }); + + // Proves creating a subscription object subscribes nothing. + step('createSubscription', { proves: 'Creating a subscription object does not subscribe anything.' }, () => { + const pubnub = world.fresh('createsub'); + // snippet.createSubscription + const channel = pubnub.channel('my_channel'); + + const subscriptionOptions = { receivePresenceEvents: true }; + channel.subscription(subscriptionOptions); + // snippet.end + // Creating a subscription object does not subscribe. + assert.deepEqual(pubnub.getSubscribedChannels(), []); + }); + + // Proves subscribing a set connects to both channels and delivers a message from the friend. + step('subscriptionSetSubscribeBasicUsage', { proves: 'Subscribing a set connects both channels and delivers a message from a friend.' }, async () => { + const pubnub = world.fresh('setsubscribe'); + const [ch1, ch2] = [world.n('ch1'), world.n('ch2')]; + const ready = connected(pubnub, { channels: [ch1, ch2] }); + const received = expectEvent(pubnub, 'message', (m) => m.channel === ch1 && (m.message as { via?: string }).via === 'set'); + + // snippet.subscriptionSetSubscribeBasicUsage + const channelsSubscriptionSet = pubnub.subscriptionSet({ channels: ['ch1', 'ch2'] }); + channelsSubscriptionSet.subscribe(); + // snippet.end + + await ready; + assert.deepEqual(sorted(pubnub.getSubscribedChannels()), sorted([ch1, ch2])); + await world.friend.publish({ channel: ch1, message: { via: 'set' } }); + await received; + channelsSubscriptionSet.unsubscribe(); + }); + + // The snippet only registers listeners. The harness subscribes the same subscription afterwards and triggers a message, + // a signal and a message action, then checks the snippet's handlers logged. Presence, objects and file handlers are + // not triggered here (presence is covered by eventListenerBasicUsage). + step('eventListenerAddListeners', { proves: 'The message, signal and message action handlers of the sample log when the events arrive.' }, async () => { + const pubnub = world.fresh('addlisteners'); + const channelName = world.n('channel_1'); + await world.withConsole(async (logs) => { + // snippet.eventListenerAddListeners + + // create a subscription from a channel entity + const channel = pubnub.channel('channel_1'); + const subscription = channel.subscription(); + + // Event-specific listeners + subscription.onMessage = (message) => { + console.log('Message event: ', message); + }; + subscription.onPresence = (presence) => { + console.log('Presence event: ', presence); + }; + subscription.onSignal = (signal) => { + console.log('Signal event: ', signal); + }; + subscription.onObjects = (objectsEvent) => { + console.log('Objects event: ', objectsEvent); + }; + subscription.onMessageAction = (messageActionEvent) => { + console.log('Message Reaction event: ', messageActionEvent); + }; + subscription.onFile = (fileEvent) => { + console.log('File event: ', fileEvent); + }; + + // Generic listeners + subscription.addListener({ + // Messages + message: function (m) { + const channelName = m.channel; // Channel on which the message was published + const channelGroup = m.subscription; // Channel group or wildcard subscription match (if exists) + const pubTT = m.timetoken; // Publish timetoken + const msg = m.message; // Message payload + const publisher = m.publisher; // Message publisher + }, + // Presence + // requires a subscription with presence + presence: function (p) { + const action = p.action; // Can be join, leave, timeout, state-change, or interval + const channelName = p.channel; // Channel to which the message belongs + const channelGroup = p.subscription; // Channel group or wildcard subscription match, if any + const publishTime = p.timestamp; // Publish timetoken + const timetoken = p.timetoken; // Current timetoken + }, + // Signals + signal: function (s) { + const channelName = s.channel; // Channel to which the signal belongs + const channelGroup = s.subscription; // Channel group or wildcard subscription match, if any + const pubTT = s.timetoken; // Publish timetoken + const msg = s.message; // Payload + const publisher = s.publisher; // Message publisher + }, + // App Context + objects: (objectEvent) => { + const channel = objectEvent.channel; // channel Id or a User Id of updated/set app context metadata object + const channelGroup = objectEvent.subscription; // Channel group + const timetoken = objectEvent.timetoken; // Event timetoken + const event = objectEvent.message.data.type; // Event name + }, + // Message Actions + messageAction: function (ma) { + const channelName = ma.channel; // Channel to which the message belongs + const publisher = ma.data.uuid; // Message publisher + const event = ma.event; // Message action added or removed + const type = ma.data.type; // Message action type + const value = ma.data.value; // Message action value + const messageTimetoken = ma.data.messageTimetoken; // Timetoken of the original message + const actionTimetoken = ma.data.actionTimetoken; // Timetoken of the message action + }, + // File Sharing + file: function (event) { + const channelName = event.channel; // Channel to which the file belongs + const channelGroup = event.subscription; // Channel group or wildcard subscription match (if exists) + const publisher = event.publisher; // File publisher + const timetoken = event.timetoken; // Event timetoken + const message = event.message; // Optional message attached to the file + }, + }); + + // snippet.end + + // The snippet only registers listeners. Subscribe and trigger events to prove they are wired. + const ready = connected(pubnub, { channels: [channelName] }); + subscription.subscribe(); + await ready; + const published = await world.friend.publish({ channel: channelName, message: { n: 1 } }); + await world.friend.signal({ channel: channelName, message: 'ping' }); + await world.friend.addMessageAction({ + channel: channelName, + messageTimetoken: published.timetoken, + action: { type: 'reaction', value: 'smiley_face' }, + }); + await eventually(() => assertAll({ + 'onMessage ran': hasLine(logs.lines, 'Message event: '), + 'onSignal ran': hasLine(logs.lines, 'Signal event: '), + 'onMessageAction ran': hasLine(logs.lines, 'Message Reaction event: '), + }), { label: 'event-specific listeners ran' }); + subscription.unsubscribe(); + }); + }); + + // Proves status, message, presence and set-level message action listeners from one snippet. + // Needs Presence (the sample asks for presence events) and has a longer timeout because presence events can arrive late. + step('eventListenerBasicUsage', { needs: ['preflightPresence'], timeout: 60_000, proves: 'Status, message, presence and set level message action listeners all fire from one sample.' }, async () => { + const pubnub = world.fresh('listenerbasic'); + const [c1, ch1, ch2] = [world.n('channel_1'), world.n('ch1'), world.n('ch2')]; + await world.withConsole(async (logs) => { + const ready = connected(pubnub, { channels: [c1, ch1, ch2] }); + + // snippet.eventListenerBasicUsage + // create a subscription from a channel entity + const channel = pubnub.channel('channel_1'); + const subscription1 = channel.subscription({ receivePresenceEvents: true }); + + // create a subscription set with multiple channels + const subscriptionSet1 = pubnub.subscriptionSet({ channels: ['ch1', 'ch2'] }); + + // add a status listener + pubnub.addListener({ + status: (s) => { + console.log('Status', s.category); + }, + }); + + // add message and presence listeners + subscription1.addListener({ + // Messages + message: (m) => { + console.log('Received message', m); + }, + // Presence + presence: (p) => { + console.log('Presence event', p); + }, + }); + + // add event-specific message actions listener + subscriptionSet1.onMessageAction = (p) => { + console.log('Message action event:', p); + }; + + subscription1.subscribe(); + subscriptionSet1.subscribe(); + // snippet.end + + await ready; + // The presence listener must see a presence event. The client's own join may fall before its first subscribe timetoken and + // never arrive, so a second client joins now that the first one is connected. Its join is published after the snippet's + // subscription is running and is announced after the keyset's 2 s debounce (the channel has far fewer than 20 occupants). + const joiner = world.fresh('listenerbasicjoiner'); + const joinerReady = connected(joiner, { channels: [c1] }); + joiner.subscribe({ channels: [c1] }); + await joinerReady; + const onChannel = await world.friend.publish({ channel: c1, message: { n: 1 } }); + const onSet = await world.friend.publish({ channel: ch1, message: { n: 2 } }); + await world.friend.addMessageAction({ + channel: ch1, + messageTimetoken: onSet.timetoken, + action: { type: 'reaction', value: 'smiley_face' }, + }); + assert.ok(onChannel.timetoken); + // One error that lists every handler that has not logged yet, not only the first one. + await eventually(() => assertAll({ + 'status listener saw the connection': hasLine(logs.lines, 'Status PNConnectedCategory'), + 'subscription message listener ran': hasLine(logs.lines, 'Received message'), + 'presence listener saw a presence event (the joiner)': hasLine(logs.lines, 'Presence event'), + 'subscription set message action listener ran': hasLine(logs.lines, 'Message action event:'), + }), { label: 'listeners from the snippet ran', timeout: PRESENCE_EVENT_TIMEOUT }); + joiner.unsubscribeAll(); + subscription1.unsubscribe(); + subscriptionSet1.unsubscribe(); + }); + }); + + // Proves the status listener fires PNConnectedCategory. The subscribe happens outside the snippet, it only registers the listener. + step('eventListenerAddConnectionStatusListenersBasicUsage', { proves: 'The status listener fires PNConnectedCategory.' }, async () => { + const pubnub = world.fresh('statuslistener'); + await world.withConsole(async (logs) => { + // snippet.eventListenerAddConnectionStatusListenersBasicUsage + // add a status listener + pubnub.addListener({ + status: (s) => { + console.log('Status', s.category); + }, + }); + // snippet.end + + const subscription = pubnub.channel(world.n('ch1')).subscription(); + subscription.subscribe(); + await eventually(() => assert.ok(hasLine(logs.lines, 'Status PNConnectedCategory')), { label: 'connected status logged' }); + subscription.unsubscribe(); + }); + }); + + // Proves unsubscribing both a subscription and a set leaves nothing subscribed. Polls, because unsubscribe applies asynchronously. + step('unsubscribeBasicUsage', { proves: 'Unsubscribing a subscription and a set leaves nothing subscribed.' }, async () => { + const pubnub = world.fresh('unsubscribe'); + // snippet.unsubscribeBasicUsage + // create a subscription from a channel entity + const channel1 = pubnub.channel('channel_1'); + const subscription1 = channel1.subscription({ receivePresenceEvents: true }); + + // create a subscription set with multiple channels + const subscriptionSet1 = pubnub.subscriptionSet({ channels: ['ch1', 'ch2'] }); + + subscription1.subscribe(); + subscriptionSet1.subscribe(); + + subscription1.unsubscribe(); + subscriptionSet1.unsubscribe(); + // snippet.end + await eventually(() => assert.deepEqual(pubnub.getSubscribedChannels(), []), { label: 'nothing left subscribed' }); + }); + + // Proves unsubscribeAll clears channels and groups. Needs the group fixture so the group subscription is real. + step('unsubscribeAllBasicUsage', { needs: ['subscribeGroupFixture'], proves: 'unsubscribeAll clears channels and groups.' }, async () => { + const pubnub = world.fresh('unsubscribeall'); + // snippet.unsubscribeAllBasicUsage + // create a subscription set with multiple channels + const subscriptionSet = pubnub.subscriptionSet({ channels: ['ch1', 'ch2'] }); + subscriptionSet.subscribe(); + + // create a subscription from a channel entity + const channelGroup1 = pubnub.channelGroup('channelGroup_1'); + const groupSubscription1 = channelGroup1.subscription({ receivePresenceEvents: true }); + groupSubscription1.subscribe(); + + // unsubscribe all active subscriptions + pubnub.unsubscribeAll(); + // snippet.end + await eventually(() => { + assert.deepEqual(pubnub.getSubscribedChannels(), []); + assert.deepEqual(pubnub.getSubscribedChannelGroups(), []); + }, { label: 'unsubscribeAll clears channels and groups' }); + }); + + // Proves add/removeSubscription: channel_3 is removed before subscribing, channel_1 and the group stay. + step('subscriptionSetFrom2IndividualSubscriptions', { needs: ['subscribeGroupFixture'], proves: 'addSubscription and removeSubscription change the set before subscribing.' }, async () => { + const pubnub = world.fresh('setfromsubs'); + const [c1, c3, group] = [world.n('channel_1'), world.n('channel_3'), world.n('channelGroup_1')]; + const ready = connected(pubnub, { channels: [c1], channelGroups: [group] }); + + // snippet.subscriptionSetFrom2IndividualSubscriptions + // create a subscription from a channel entity + const channel = pubnub.channel('channel_1'); + const subscription1 = channel.subscription({ receivePresenceEvents: true }); + + // create a subscription from a channel group entity + const channelGroup = pubnub.channelGroup('channelGroup_1'); + const subscription2 = channelGroup.subscription(); + + // add 2 subscriptions to create a subscription set + const subscriptionSet = subscription1.addSubscription(subscription2); + + // add another subscription to the set + const subscription3 = pubnub.channel('channel_3').subscription({ receivePresenceEvents: false }); + subscriptionSet.addSubscription(subscription3); + + // remove a subscription from a subscription set + subscriptionSet.removeSubscription(subscription3); + + subscriptionSet.subscribe(); + // snippet.end + + await ready; + const channels = pubnub.getSubscribedChannels(); + assert.ok(channels.includes(c1), 'channel_1 is subscribed'); + assert.ok(!channels.includes(c3), 'channel_3 was removed before subscribing'); + assert.ok(pubnub.getSubscribedChannelGroups().includes(group), 'channelGroup_1 is subscribed'); + pubnub.unsubscribeAll(); + }); + + // Proves add/removeSubscriptionSet is a local operation: nothing is subscribed and the first set is back to its own channels. + step('SubscriptionSetFrom2Sets', { proves: 'addSubscriptionSet and removeSubscriptionSet are local operations and subscribe nothing.' }, () => { + const pubnub = world.fresh('setfromsets'); + // snippet.SubscriptionSetFrom2Sets + // create a subscription set with multiple channels + const subscriptionSet1 = pubnub.subscriptionSet({ channels: ['ch1', 'ch2'] }); + + // create another subscription set with multiple channels and options + const subscriptionSet2 = pubnub.subscriptionSet({ + channels: ['ch3', 'ch4'], + subscriptionOptions: { receivePresenceEvents: true }, + }); + + // add a subscription set to another subscription set + subscriptionSet1.addSubscriptionSet(subscriptionSet2); + + // remove a subscription set from another subscription set + subscriptionSet1.removeSubscriptionSet(subscriptionSet2); + // snippet.end + // Nothing subscribed, and the first set is back to its own two channels. + assert.deepEqual(pubnub.getSubscribedChannels(), []); + assert.deepEqual(sorted(subscriptionSet1.channels), sorted([world.n('ch1'), world.n('ch2')])); + }); + + // Proves that adding sets and subscriptions to an already subscribed set takes effect at once: a message on ch4 and one via the group arrive. + step('AddToExistingSubscriptionSet', { needs: ['subscribeGroupFixture'], proves: 'Adding sets and subscriptions to an already subscribed set takes effect at once.' }, async () => { + const pubnub = world.fresh('addtoset'); + const [ch1, ch2, ch3, ch4] = ['ch1', 'ch2', 'ch3', 'ch4'].map((c) => world.n(c)); + const [group, member] = [world.n('channelGroup_1'), world.n('channelGroup_1_member')]; + const ready = connected(pubnub, { channels: [ch1, ch2, ch3, ch4], channelGroups: [group] }); + const onLateChannel = expectEvent(pubnub, 'message', (m) => m.channel === ch4 && (m.message as { n?: number }).n === 4); + const onGroup = expectEvent(pubnub, 'message', (m) => m.channel === member && m.subscription === group); + + // snippet.AddToExistingSubscriptionSet + // create a subscription set with multiple channels + const subscriptionSet1 = pubnub.subscriptionSet({ channels: ['ch1', 'ch2'] }); + + // subscribe to the set + // start receiving events from ch1 and ch2 + subscriptionSet1.subscribe(); + + // create another subscription set with multiple channels + const subscriptionSet2 = pubnub.subscriptionSet({ channels: ['ch3', 'ch4'] }); + + // add the new set to the initial set + subscriptionSet1.addSubscriptionSet(subscriptionSet2); + + // you're now receiving events from ch1, ch2, ch3, and ch4 + // because the set has been subscribed to previously + + // create and add another subscription to the set + const channelGroup = pubnub.channelGroup('channelGroup_1'); + const subscription2 = channelGroup.subscription(); + subscriptionSet1.addSubscription(subscription2); + + // you're now receiving events from ch1, ch2, ch3, and ch4 and channelGroup_1 + // snippet.end + + await ready; + await world.friend.publish({ channel: ch4, message: { n: 4 } }); + await world.friend.publish({ channel: member, message: { n: 5 } }); + await onLateChannel; + await onGroup; + subscriptionSet1.unsubscribe(); + }); +}); diff --git a/docs-snippets/coverage.json b/docs-snippets/coverage.json new file mode 100644 index 000000000..e5c29e0d8 --- /dev/null +++ b/docs-snippets/coverage.json @@ -0,0 +1,1917 @@ +{ + "$comment": "Generated by npm run docs-snippets:manifest. Do not edit. One entry per snippet id: how the live suite covers it.", + "snippets": { + "AddToExistingSubscriptionSet": { + "file": "api-reference/subscribe.ts", + "step": "AddToExistingSubscriptionSet", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "OLDUnsubscribeAllBasicUsage": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDUnsubscribeAllBasicUsage", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDUnsubscribeBasicUsage": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDUnsubscribeBasicUsage", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDUnsubscribeMultipleChannelGroup": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDUnsubscribeMultipleChannelGroup", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDUnsubscribeMultipleChannels": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDUnsubscribeMultipleChannels", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDaddListeners": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDaddListeners", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDremoveListeners": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDremoveListeners", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeBasicUsage": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeBasicUsage", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeChannelGroup": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeChannelGroup", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeChannelGroupAndChannels": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeChannelGroupAndChannels", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeChannelGroupWithPresence": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeChannelGroupWithPresence", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeMultipleChannelGroup": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeMultipleChannelGroup", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeMultipleChannels": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeMultipleChannels", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeWithPresence": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeWithPresence", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeWithState": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeWithState", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "OLDsubscribeWithWildCardChannels": { + "file": "api-reference/subscribe-legacy.ts", + "step": "OLDsubscribeWithWildCardChannels", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "PubNubinitBasicUsage": { + "file": "shared/import-pubnub.ts", + "step": "PubNubinitBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "SubscriptionSetFrom2Sets": { + "file": "api-reference/subscribe.ts", + "step": "SubscriptionSetFrom2Sets", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "accessManagerBasicUsage": { + "file": "api-reference/access-manager.ts", + "step": "accessManagerBasicUsage", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "addChannelsToGroupBasicUsage": { + "file": "api-reference/channel-groups.ts", + "step": "addChannelsToGroupBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "addDataSyncListener": { + "file": "api-reference/datasync.ts", + "step": "addDataSyncListener", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "addDeciveToChannelBasicUsage": { + "file": "api-reference/mobile-push.ts", + "step": "addDeciveToChannelBasicUsage", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "addMessageActionBasicUsage": { + "file": "api-reference/message-actions.ts", + "step": "addMessageActionBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "basicUsageWithPromises": { + "file": "api-reference/presence.ts", + "step": "basicUsageWithPromises", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "buildNotificationPayloadBasicUsage": { + "file": "api-reference/mobile-push.ts", + "step": "buildNotificationPayloadBasicUsage", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "combineDataSyncSubscriptions": { + "file": "api-reference/datasync.ts", + "step": "combineDataSyncSubscriptions", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "configurationBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "configurationBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "configurationBasicUsageSubscriptionWorkerUrl": { + "file": "quickstarts/browser-shared-worker.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "browser-only, type checked by tsconfig.browser.json" + }, + "configurationCryptoModule": { + "file": "api-reference/configuration.ts", + "step": "configurationCryptoModule", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "configurationRealOnlyClient": { + "file": "api-reference/configuration.ts", + "step": "configurationRealOnlyClient", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "configurationServerOpertaion": { + "file": "api-reference/configuration.ts", + "step": "configurationServerOpertaion", + "role": "pam", + "phase": "P0", + "mode": "live", + "reason": null + }, + "createChannelBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "createChannelBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "createChannelGroupBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "createChannelGroupBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "createChannelMetadataBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "createChannelMetadataBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "createDataSyncChannelBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "createDataSyncChannelBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "createEntityBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "createEntityBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "createMembershipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "createMembershipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "createRelationshipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "createRelationshipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "createSubscription": { + "file": "api-reference/subscribe.ts", + "step": "createSubscription", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "createUserBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "createUserBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "createUserMetadataBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "createUserMetadataBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "dataSyncEntitySubscribeExample": { + "file": "api-reference/datasync.ts", + "step": "dataSyncEntitySubscribeExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "dataSyncProjectionSubscriptions": { + "file": "api-reference/datasync.ts", + "step": "dataSyncProjectionSubscriptions", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "decryptBasicUsage": { + "file": "api-reference/encryption-legacy.ts", + "step": "decryptBasicUsage", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "decryptFileBasicUsage": { + "file": "api-reference/encryption.ts", + "step": "decryptFileBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "deleteChannelGroupBasicUsage": { + "file": "api-reference/channel-groups.ts", + "step": "deleteChannelGroupBasicUsage", + "role": "open", + "phase": "P9", + "mode": "live", + "reason": null + }, + "deleteFileBasicUsage": { + "file": "api-reference/file-sharing.ts", + "step": "deleteFileBasicUsage", + "role": "open", + "phase": "P9", + "mode": "live", + "reason": null + }, + "deleteMessagesBasicUsage": { + "file": "api-reference/message-persistence.ts", + "step": "deleteMessagesBasicUsage", + "role": "open", + "phase": "P5", + "mode": "live", + "reason": null + }, + "deleteProxy": { + "file": "api-reference/misc.ts", + "step": "deleteProxy", + "role": "open", + "phase": "P7", + "mode": "live", + "reason": null + }, + "deleteSpecificMessages": { + "file": "api-reference/message-persistence.ts", + "step": "deleteSpecificMessages", + "role": "open", + "phase": "P5", + "mode": "live", + "reason": null + }, + "destroyBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "destroyBasicUsage", + "role": "open", + "phase": "P9", + "mode": "live", + "reason": null + }, + "disconnectBasicUsage": { + "file": "api-reference/misc.ts", + "step": "disconnectBasicUsage", + "role": "open", + "phase": "P7", + "mode": "live", + "reason": null + }, + "downloadFileCustomCipherKey": { + "file": "api-reference/file-sharing-legacy.ts", + "step": "downloadFileCustomCipherKey", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "downloadFileNodeBasicUsage": { + "file": "api-reference/file-sharing.ts", + "step": "downloadFileNodeBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "downloadFileReactNativeBasicUsage": { + "file": "quickstarts/react-native-download-file.ts", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "downloadFileWebBasicUsage": { + "file": "quickstarts/browser-download-file.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "browser-only, type checked by tsconfig.browser.json" + }, + "encryptFileBasicUsage": { + "file": "api-reference/encryption.ts", + "step": "encryptFileBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "encryptMessageBasicUsage": { + "file": "api-reference/encryption.ts", + "step": "encryptMessageBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "eventListenerAddConnectionStatusListenersBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "eventListenerAddConnectionStatusListenersBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "eventListenerAddListeners": { + "file": "api-reference/subscribe.ts", + "step": "eventListenerAddListeners", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "eventListenerBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "eventListenerBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "fetchMessagesBasicUsage": { + "file": "api-reference/message-persistence.ts", + "step": "fetchMessagesBasicUsage", + "role": "open", + "phase": "P5", + "mode": "live", + "reason": null + }, + "fetchMessagesWithMetaActions": { + "file": "api-reference/message-persistence.ts", + "step": "fetchMessagesWithMetaActions", + "role": "open", + "phase": "P5", + "mode": "live", + "reason": null + }, + "fileInputBrowserForms": { + "file": null, + "step": "fileInputBrowserForms", + "role": "open", + "phase": "P4", + "mode": "stub", + "reason": "browser-only" + }, + "fileInputReactNativeUri": { + "file": null, + "step": "fileInputReactNativeUri", + "role": "open", + "phase": "P4", + "mode": "stub", + "reason": "react-native" + }, + "fireBasicUsage": { + "file": "api-reference/publish.ts", + "step": "fireBasicUsage", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "getAllChannelMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "getAllChannelMetadataBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "getAllUUIDMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "getAllUUIDMetadataBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "getAuthKeyBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "getAuthKeyBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "getChannelBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getChannelBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getChannelMembersBasicUsage": { + "file": "api-reference/objects.ts", + "step": "getChannelMembersBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "getChannelMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "getChannelMetadataBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "getChannelsBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getChannelsBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getChannelsCursorPagingExample": { + "file": "api-reference/datasync.ts", + "step": "getChannelsCursorPagingExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getChannelsFilterExample": { + "file": "api-reference/datasync.ts", + "step": "getChannelsFilterExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getChannelsFilterFastExample": { + "file": "api-reference/datasync.ts", + "step": "getChannelsFilterFastExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getDataSyncMembershipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getDataSyncMembershipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getEntitiesBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getEntitiesBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getEntitiesCursorPagingExample": { + "file": "api-reference/datasync.ts", + "step": "getEntitiesCursorPagingExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getEntitiesFilterExample": { + "file": "api-reference/datasync.ts", + "step": "getEntitiesFilterExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getEntitiesFilterFastExample": { + "file": "api-reference/datasync.ts", + "step": "getEntitiesFilterFastExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getEntityBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getEntityBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getFileUrlBasicUsage": { + "file": "api-reference/file-sharing.ts", + "step": "getFileUrlBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "getFilterExpressionBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "getFilterExpressionBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "getMembershipBasicUsage": { + "file": "api-reference/objects.ts", + "step": "getMembershipBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "getMembershipsBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getMembershipsBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getMembershipsByChannelIdExample": { + "file": "api-reference/datasync.ts", + "step": "getMembershipsByChannelIdExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getMembershipsCursorPagingExample": { + "file": "api-reference/datasync.ts", + "step": "getMembershipsCursorPagingExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getMembershipsFilterExample": { + "file": "api-reference/datasync.ts", + "step": "getMembershipsFilterExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getMembershipsFilterFastExample": { + "file": "api-reference/datasync.ts", + "step": "getMembershipsFilterFastExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getMessageActionsBasicUsage": { + "file": "api-reference/message-actions.ts", + "step": "getMessageActionsBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "getRelationshipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getRelationshipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getRelationshipsBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getRelationshipsBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getRelationshipsByEntityBIdExample": { + "file": "api-reference/datasync.ts", + "step": "getRelationshipsByEntityBIdExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getRelationshipsCursorPagingExample": { + "file": "api-reference/datasync.ts", + "step": "getRelationshipsCursorPagingExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getRelationshipsFilterExample": { + "file": "api-reference/datasync.ts", + "step": "getRelationshipsFilterExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getRelationshipsFilterFastExample": { + "file": "api-reference/datasync.ts", + "step": "getRelationshipsFilterFastExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getStateBasicUsage": { + "file": "api-reference/presence.ts", + "step": "getStateBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "getUUIDMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "getUUIDMetadataBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "getUserBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getUserBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getUserIdBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "getUserIdBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "getUsersBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "getUsersBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getUsersCursorPagingExample": { + "file": "api-reference/datasync.ts", + "step": "getUsersCursorPagingExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getUsersFilterExample": { + "file": "api-reference/datasync.ts", + "step": "getUsersFilterExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "getUsersFilterFastExample": { + "file": "api-reference/datasync.ts", + "step": "getUsersFilterFastExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "gettingStartedCreateSubscription": { + "file": "getting-started/quickstart-node.ts", + "step": "gettingStartedInitPubnub", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "gettingStartedInitPubnub": { + "file": "getting-started/quickstart-node.ts", + "step": "gettingStartedInitPubnub", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "grantAllChannelsAllAuthKeys": { + "file": "api-reference/access-manager-v2.ts", + "step": "grantAllChannelsAllAuthKeys", + "role": "pam", + "phase": "P9", + "mode": "live", + "reason": null + }, + "grantBasicUsage": { + "file": "api-reference/access-manager-v2.ts", + "step": "grantBasicUsage", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantChannelAllAuthKeys": { + "file": "api-reference/access-manager-v2.ts", + "step": "grantChannelAllAuthKeys", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantChannelOnAuthKey": { + "file": "api-reference/access-manager-v2.ts", + "step": "grantChannelOnAuthKey", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantPresenceChannelOnAuthKey": { + "file": "api-reference/access-manager-v2.ts", + "step": "grantPresenceChannelOnAuthKey", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantTokenDataSyncResources": { + "file": "api-reference/access-manager.ts", + "step": "grantTokenDataSyncResources", + "role": "datasync", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantTokenRegExAndResources": { + "file": "api-reference/access-manager.ts", + "step": "grantTokenRegExAndResources", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantTokenSpacesBasicUsage": { + "file": "api-reference/access-manager-legacy.ts", + "step": "grantTokenSpacesBasicUsage", + "role": "pam", + "phase": "P8", + "mode": "live", + "reason": null + }, + "grantTokenSpacesRegEx": { + "file": "api-reference/access-manager-legacy.ts", + "step": "grantTokenSpacesRegEx", + "role": "pam", + "phase": "P8", + "mode": "live", + "reason": null + }, + "grantTokenSpacesRegExAndResources": { + "file": "api-reference/access-manager-legacy.ts", + "step": "grantTokenSpacesRegExAndResources", + "role": "pam", + "phase": "P8", + "mode": "live", + "reason": null + }, + "grantTokenSpacesVariousResources": { + "file": "api-reference/access-manager-legacy.ts", + "step": "grantTokenSpacesVariousResources", + "role": "pam", + "phase": "P8", + "mode": "live", + "reason": null + }, + "grantTokenUsingRegEx": { + "file": "api-reference/access-manager.ts", + "step": "grantTokenUsingRegEx", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantTokenVariousResources": { + "file": "api-reference/access-manager.ts", + "step": "grantTokenVariousResources", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantTokenWithCategories": { + "file": "api-reference/access-manager.ts", + "step": "grantTokenWithCategories", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantTokenWithProjection": { + "file": "api-reference/access-manager.ts", + "step": "grantTokenWithProjection", + "role": "datasync", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantUuidOnAuthKey": { + "file": "api-reference/access-manager-v2.ts", + "step": "grantUuidOnAuthKey", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "grantWithCategories": { + "file": "api-reference/access-manager-v2.ts", + "step": "grantWithCategories", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "hereNowBasicUsage": { + "file": "api-reference/presence.ts", + "step": "hereNowBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "hereNowChannelGroup": { + "file": "api-reference/presence.ts", + "step": "hereNowChannelGroup", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "hereNowFetchOccupancyOnly": { + "file": "api-reference/presence.ts", + "step": "hereNowFetchOccupancyOnly", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "hereNowWithState": { + "file": "api-reference/presence.ts", + "step": "hereNowWithState", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "historyBasicUsage": { + "file": "api-reference/message-persistence-legacy.ts", + "step": "historyBasicUsage", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "historyIncludeMeta": { + "file": "api-reference/message-persistence-legacy.ts", + "step": "historyIncludeMeta", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "historyNewerThanTimetoken": { + "file": "api-reference/message-persistence-legacy.ts", + "step": "historyNewerThanTimetoken", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "historyOldestThree": { + "file": "api-reference/message-persistence-legacy.ts", + "step": "historyOldestThree", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "historyPagingExample": { + "file": "api-reference/message-persistence-legacy.ts", + "step": "historyPagingExample", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "historyUntilTimetoken": { + "file": "api-reference/message-persistence-legacy.ts", + "step": "historyUntilTimetoken", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "historyWithPromises": { + "file": "api-reference/message-persistence-legacy.ts", + "step": "historyWithPromises", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "importFS": { + "file": "shared/import-pubnub.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "import-only snippet, type checked by tsconfig.live.json" + }, + "importPubNub": { + "file": "shared/import-pubnub.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "import-only snippet, type checked by tsconfig.live.json" + }, + "iterativelyUpdateExistingMetadata": { + "file": "api-reference/objects.ts", + "step": "iterativelyUpdateExistingMetadata", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "listChannelsForDeviceBasicUsage": { + "file": "api-reference/mobile-push.ts", + "step": "listChannelsForDeviceBasicUsage", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "listChannelsInGroupBasicUsage": { + "file": "api-reference/channel-groups.ts", + "step": "listChannelsInGroupBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "listFilesBasicUsage": { + "file": "api-reference/file-sharing.ts", + "step": "listFilesBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "loggingBasicUsage": { + "file": "getting-started/logging.ts", + "step": "loggingBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "loggingCustomLoggerExample": { + "file": "getting-started/logging.ts", + "step": "loggingCustomLoggerExample", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "loggingFileLogger": { + "file": "getting-started/logging-file-logger.ts", + "step": "loggingFileLogger", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "loggingFileLoggerImports": { + "file": "getting-started/logging-file-logger.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "import-only snippet, type checked by tsconfig.live.json" + }, + "loggingImportLogger": { + "file": "getting-started/logging.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "import-only snippet, type checked by tsconfig.live.json" + }, + "loggingImportPubNub": { + "file": "getting-started/logging.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "import-only snippet, type checked by tsconfig.live.json" + }, + "loggingMultipleLoggers": { + "file": "getting-started/logging.ts", + "step": "loggingMultipleLoggers", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "messageCountBasicUsage": { + "file": "api-reference/message-persistence.ts", + "step": "messageCountBasicUsage", + "role": "open", + "phase": "P5", + "mode": "live", + "reason": null + }, + "messageCountTimetokensForChannels": { + "file": "api-reference/message-persistence.ts", + "step": "messageCountTimetokensForChannels", + "role": "open", + "phase": "P5", + "mode": "live", + "reason": null + }, + "migrationV11LegacySubscriptionManager": { + "file": "migration-guides/v11.ts", + "step": "migrationV11LegacySubscriptionManager", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "migrationV11RestoreFalse": { + "file": "migration-guides/v11.ts", + "step": "migrationV11RestoreFalse", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "migrationV8AfterImport": { + "file": "migration-guides/v8.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "import-only snippet, type checked by tsconfig.live.json" + }, + "migrationV8AfterParams": { + "file": "migration-guides/v8.ts", + "step": "migrationV8AfterParams", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "observeDataSyncEntitiesByPattern": { + "file": "api-reference/datasync.ts", + "step": "observeDataSyncEntitiesByPattern", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "parseTokenBasicUsage": { + "file": "api-reference/access-manager.ts", + "step": "parseTokenBasicUsage", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "parseTokenWithCategories": { + "file": "api-reference/access-manager.ts", + "step": "parseTokenWithCategories", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "patchChannelBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "patchChannelBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "patchEntityBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "patchEntityBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "patchMembershipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "patchMembershipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "patchRelationshipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "patchRelationshipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "patchUserBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "patchUserBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "publishBasicUsage": { + "file": "api-reference/publish.ts", + "step": "publishBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "publishFalsyMessage": { + "file": "api-reference/publish.ts", + "step": "publishFalsyMessage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "publishFileMessageBasicUsage": { + "file": "api-reference/file-sharing.ts", + "step": "publishFileMessageBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "publishHandleErrors": { + "file": "api-reference/publish.ts", + "step": "publishHandleErrors", + "role": "pam", + "phase": "P4", + "mode": "live", + "reason": null + }, + "publishMinimal": { + "file": "api-reference/publish.ts", + "step": "publishMinimal", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "publishStoreThePublishedMessagefor10Hours": { + "file": "api-reference/publish.ts", + "step": "publishStoreThePublishedMessagefor10Hours", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "quickstartNodeListeners": { + "file": "getting-started/quickstart-node.ts", + "step": "gettingStartedInitPubnub", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "quickstartNodePublish": { + "file": "getting-started/quickstart-node.ts", + "step": "gettingStartedInitPubnub", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "quickstartReactImports": { + "file": "quickstarts/quickstart-react.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactInit": { + "file": "quickstarts/quickstart-react.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactNativeCleanup": { + "file": "quickstarts/quickstart-react-native.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactNativeImport": { + "file": "quickstarts/quickstart-react-native.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactNativeInit": { + "file": "quickstarts/quickstart-react-native.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactNativePublish": { + "file": "quickstarts/quickstart-react-native.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactNativeSubscription": { + "file": "quickstarts/quickstart-react-native.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactPublish": { + "file": "quickstarts/quickstart-react.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartReactSubscribe": { + "file": "quickstarts/quickstart-react.tsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "quickstartWebInit": { + "file": "quickstarts/quickstart-web.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "browser-only, type checked by tsconfig.browser.json" + }, + "quickstartWebListeners": { + "file": "quickstarts/quickstart-web.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "browser-only, type checked by tsconfig.browser.json" + }, + "quickstartWebPublish": { + "file": "quickstarts/quickstart-web.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "browser-only, type checked by tsconfig.browser.json" + }, + "quickstartWebSubscribe": { + "file": "quickstarts/quickstart-web.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "browser-only, type checked by tsconfig.browser.json" + }, + "reactConfigurationLifecycle": { + "file": "quickstarts/react-configuration.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactFetchMessages": { + "file": "quickstarts/react-fetch-messages.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactGetState": { + "file": "quickstarts/react-get-state.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactHereNow": { + "file": "quickstarts/react-here-now.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactListeners": { + "file": "quickstarts/react-listeners.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactPresenceEvents": { + "file": "quickstarts/react-presence-events.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactSetState": { + "file": "quickstarts/react-set-state.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactSubscribe": { + "file": "quickstarts/react-subscribe.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reactWhereNow": { + "file": "quickstarts/react-where-now.jsx", + "step": null, + "role": null, + "phase": null, + "mode": "stub", + "reason": "react or react-native source, kept for the docs URLs, cannot be run in this repository" + }, + "reconnectBasicUsage": { + "file": "api-reference/misc.ts", + "step": "reconnectBasicUsage", + "role": "open", + "phase": "P7", + "mode": "live", + "reason": null + }, + "removeAllMobilePushNotificationsBasicUsage": { + "file": "api-reference/mobile-push.ts", + "step": "removeAllMobilePushNotificationsBasicUsage", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "removeChannelBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "removeChannelBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "removeChannelMembersBasicUsage": { + "file": "api-reference/objects.ts", + "step": "removeChannelMembersBasicUsage", + "role": "open", + "phase": "P9", + "mode": "live", + "reason": null + }, + "removeChannelMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "removeChannelMetadataBasicUsage", + "role": "open", + "phase": "P9", + "mode": "live", + "reason": null + }, + "removeChannelsFromGroupBasicUsage": { + "file": "api-reference/channel-groups.ts", + "step": "removeChannelsFromGroupBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "removeDeviceFromChannelBasicUsage": { + "file": "api-reference/mobile-push.ts", + "step": "removeDeviceFromChannelBasicUsage", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "removeEntityBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "removeEntityBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "removeMembershipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "removeMembershipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "removeMembershipsBasicUsage": { + "file": "api-reference/objects.ts", + "step": "removeMembershipsBasicUsage", + "role": "open", + "phase": "P9", + "mode": "live", + "reason": null + }, + "removeMessageActionBasicUsage": { + "file": "api-reference/message-actions.ts", + "step": "removeMessageActionBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "removeRelationshipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "removeRelationshipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "removeUUIDMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "removeUUIDMetadataBasicUsage", + "role": "open", + "phase": "P9", + "mode": "live", + "reason": null + }, + "removeUserBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "removeUserBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "revokeTokenBasicUsage": { + "file": "api-reference/access-manager.ts", + "step": "revokeTokenBasicUsage", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "sendFileBasicUsage": { + "file": "api-reference/file-sharing.ts", + "step": "sendFileBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "sendFileCustomCipherKey": { + "file": "api-reference/file-sharing-legacy.ts", + "step": "sendFileCustomCipherKey", + "role": "open", + "phase": "P8", + "mode": "live", + "reason": null + }, + "setAuthKeyBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "setAuthKeyBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "setChannelMembersBasicUsage": { + "file": "api-reference/objects.ts", + "step": "setChannelMembersBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "setChannelMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "setChannelMetadataBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "setFilterExpressionBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "setFilterExpressionBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "setMembershipBasicUsage": { + "file": "api-reference/objects.ts", + "step": "setMembershipBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "setProxyBasicUsage": { + "file": "api-reference/misc.ts", + "step": "setProxyBasicUsage", + "role": "open", + "phase": "P7", + "mode": "live", + "reason": null + }, + "setStateBasicUsage": { + "file": "api-reference/presence.ts", + "step": "setStateBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "setTokenBasicUsage": { + "file": "api-reference/access-manager.ts", + "step": "setTokenBasicUsage", + "role": "pam", + "phase": "P1", + "mode": "live", + "reason": null + }, + "setUUIDMetadataBasicUsage": { + "file": "api-reference/objects.ts", + "step": "setUUIDMetadataBasicUsage", + "role": "open", + "phase": "P3", + "mode": "live", + "reason": null + }, + "setUpTypeScriptImport": { + "file": "getting-started/set-up-your-environment.ts", + "step": null, + "role": null, + "phase": null, + "mode": "compile-only", + "reason": "import-only snippet, type checked by tsconfig.live.json" + }, + "setUpTypeScriptInit": { + "file": "getting-started/set-up-your-environment.ts", + "step": "setUpTypeScriptInit", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "setUserIdBasicUsage": { + "file": "api-reference/configuration.ts", + "step": "setUserIdBasicUsage", + "role": "open", + "phase": "P0", + "mode": "live", + "reason": null + }, + "signalBasicUsage": { + "file": "api-reference/publish.ts", + "step": "signalBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "simpleNotificationPayloadFCMandAPNS": { + "file": "api-reference/mobile-push.ts", + "step": "simpleNotificationPayloadFCMandAPNS", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "simpleNotificationPayloadFCMandAPNSH/2": { + "file": "api-reference/mobile-push.ts", + "step": "simpleNotificationPayloadFCMandAPNSH/2", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "simpleNotificationPayloadFCMandAPNSH/2CustomConfiguration": { + "file": "api-reference/mobile-push.ts", + "step": "simpleNotificationPayloadFCMandAPNSH/2CustomConfiguration", + "role": "open", + "phase": "P6", + "mode": "live", + "reason": null + }, + "statusEventsMigrationAfter": { + "file": "getting-started/status-events.ts", + "step": "statusEventsMigrationAfter", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "statusEventsMigrationBefore": { + "file": "getting-started/status-events.ts", + "step": "statusEventsMigrationBefore", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "subscribeToDataSyncEntity": { + "file": "api-reference/datasync.ts", + "step": "subscribeToDataSyncEntity", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "subscriptionSetFrom2IndividualSubscriptions": { + "file": "api-reference/subscribe.ts", + "step": "subscriptionSetFrom2IndividualSubscriptions", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "subscriptionSetSubscribeBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "subscriptionSetSubscribeBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "unsubscribeAllBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "unsubscribeAllBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "unsubscribeBasicUsage": { + "file": "api-reference/subscribe.ts", + "step": "unsubscribeBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + }, + "updateChannelBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "updateChannelBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "updateEntityBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "updateEntityBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "updateEntityCombinedPatchExample": { + "file": "api-reference/datasync.ts", + "step": "updateEntityCombinedPatchExample", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "updateMembershipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "updateMembershipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "updateRelationshipBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "updateRelationshipBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "updateUserBasicUsage": { + "file": "api-reference/datasync.ts", + "step": "updateUserBasicUsage", + "role": "datasync", + "phase": "P2", + "mode": "live", + "reason": null + }, + "whereNowBasicUsage": { + "file": "api-reference/presence.ts", + "step": "whereNowBasicUsage", + "role": "open", + "phase": "P4", + "mode": "live", + "reason": null + } + } +} diff --git a/docs-snippets/fixtures/datasync-fragments.ts b/docs-snippets/fixtures/datasync-fragments.ts new file mode 100644 index 000000000..79138f172 --- /dev/null +++ b/docs-snippets/fixtures/datasync-fragments.ts @@ -0,0 +1,48 @@ +import type PubNub from 'pubnub'; + +/** + * The four fragment blocks of the DataSync page (docs/sdks/javascript/api-reference/datasync.md), one object per docs + * line. The docs show these as bare option lines (`filterFast: 'price < 100'`) inside prose, so they cannot be a + * snippet: several lines repeat the same key and are not statements. They stay hardcoded in the docs and this file + * keeps them honest in two ways. + * + * 1. Types: every entry is typed against the real SDK parameter types, so a renamed option or a `sort` shape the SDK + * no longer accepts breaks `npm run test:docs-snippets:types`. + * 2. Behaviour: the + * live check `datasyncFragments` (api-reference/datasync.ts) sends each entry to the service. + * + * Keep each entry on one line, written exactly like the docs line without its trailing comment. The text check + * relies on that. + */ + +// Sneaker Drop context: `price`, `stock`, `status` and `name` are fields of the provisioned `product` class (provision/). +type Query = PubNub.DataSync.GetEntitiesParameters; +type Patch = PubNub.DataSync.UpdateEntityParameters; + +/** Block "Some examples" (filter and filterFast expressions). */ +export const filterFragments: Array> = [ + { filterFast: 'price < 100' }, + { filterFast: '(price < 100 && stock > 0) || price > 500' }, + { filter: 'name LIKE "*sneaker*"' }, + { filter: '!(status == "discontinued")' }, +]; + +/** The two `sort` blocks: object form first, then the raw string form. */ +export const sortFragments: Array> = [ + { sort: { price: 'desc' } }, + { sort: { type: 'asc', price: 'desc' } }, + { sort: { createdAt: null } }, + { sort: 'price:desc' }, + { sort: 'type,price:desc' }, + { sort: 'createdAt' }, +]; + +/** Block "Correct patch forms" (JSON Patch maps of `update*`). */ +export const patchFragments: Array> = [ + { add: { '/payload/tags/0': 'featured' } }, + { replace: { '/payload/price': 79.99 } }, + { remove: ['/payload/tempFlag', '/payload/legacy/field'] }, + { move: [{ from: '/payload/legacyName', path: '/payload/displayName' }] }, + { copy: [{ from: '/payload/displayName', path: '/payload/previousName' }] }, + { test: { '/status': 'active' } }, +]; diff --git a/docs-snippets/fixtures/files/cat_picture.jpg b/docs-snippets/fixtures/files/cat_picture.jpg new file mode 100644 index 000000000..6878c904f Binary files /dev/null and b/docs-snippets/fixtures/files/cat_picture.jpg differ diff --git a/docs-snippets/fixtures/names.ts b/docs-snippets/fixtures/names.ts new file mode 100644 index 000000000..bc4327531 --- /dev/null +++ b/docs-snippets/fixtures/names.ts @@ -0,0 +1,182 @@ +/** + * Resource-name registry. + * + * Docs snippets show plain literals such as `my_channel`. Every literal listed here names a live resource, + * so `harness/stage.ts` rewrites it inside snippet regions of a staged copy to carry the per-run suffix. + * Source files stay untouched, which is what the docs fetch. + * + * Three kinds of entries: + * - `names`: live resource names. Rewritten to `-r<6 base36>`. A name with dots gets the suffix on its + * first dot segment so wildcard roots stay intact (`chats.room1` becomes `chats-rk3x9q.room1`). + * - `placeholders`: literals such as `replace_with_message_timetoken`. Rewritten to `world.ph('')`, so the + * step that produced the real value can supply it at run time. + * - `harnessOnly`: names the harness itself creates (outside snippet markers). They are never rewritten, the + * harness calls `world.n(name)` for them. Listed so the janitor knows what to clean. + */ + +import { createHash } from 'node:crypto'; +import { runIdAgeMs } from '../harness/env.js'; + +/** A realistic 64-character hex device token, the shape of an APNs token. Shown in the push snippets. */ +export const DEVICE_TOKEN_EXAMPLE = '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad'; + +/** Groups the registry by what the name identifies. Only used to organize `names`. Rewriting treats all kinds alike. */ +export type NameKind = 'channel' | 'channelGroup' | 'userId' | 'metadata' | 'device' | 'dataSync'; + +/** The literals the stage rewrite suffixes. Add a literal here when a snippet names a new live resource. */ +export const names: Readonly> = { + channel: [ + 'my_channel', + 'my-channel', + 'ch1', + 'ch2', + 'ch3', + 'ch4', + 'channel_1', + 'channel_3', + 'channel_name', + 'chats.room1', + 'chats.room2', + 'foo', + 'son', + 'team.blue', + 'team.red', + 'channel-with-status-type', + 'ch-1', + 'ch-2', + // Channels of the legacy subscribe, history and quickstart pages. + 'my_channel_1', + 'my_channel_2', + 'my_channel_3', + 'ab.*', + 'chan1', + 'chan2', + 'chan3', + 'history_channel', + 'history_test', + 'hello_world', + 'myChannel', + 'awesomeChannel', + 'chat-bot', + 'a', + 'b', + ], + channelGroup: ['myChannelGroup', 'family', 'cg1', 'my_channel_group', 'channelGroup_1', 'my_channelGroup', 'my_channelGroup1', 'my_channelGroup2', 'my_channelGroup3', 'demo_group1', 'demo_group2'], + userId: ['YOUR_USER_ID', 'myUniqueUserId', 'uuid', 'myUuid', 'my-uuid', 'uuid-1', 'uuid-2', 'uuid-3', 'my-authorized-uuid', 'my-authorized-userId', 'my_authkey', 'key1', 'my-auth-key', 'uuid1', 'user-alice', 'your-user-id', 'unique-user-id', 'myUserId'], + metadata: ['user_meta1'], + /** + * Push device tokens. The janitor removes their registrations. + * + * The literal must be a realistic token that the real service accepts on EVERY gateway. The docs sample uses + * `niceDevice`, which the service rejects for apns and apns2 (400 "Invalid device token", verified live 2026-11-02), + * because it checks the token FORMAT for those gateways: a hex string of 64 characters (40 also passes) is accepted, + * and FCM accepts any string. So the suite's snippet bodies show a 64-character hex token. The staging rewrite + * replaces it with another 64-character hex token derived from the run id (see `deviceToken`), so the rewritten + * value stays valid for apns and apns2. Tokens are not checked against Apple or Google until a push is sent. + */ + device: [DEVICE_TOKEN_EXAMPLE], + /** DataSync object ids on the secured keyset. Ids allow hyphens and dots but no underscores, so the run suffix fits. `user-alice` is a userId above. */ + dataSync: [ + 'channel-summer-sale', + 'membership-alice-summer-sale', + 'product-sneaker-42', + 'seller-bob', + 'rel-bob-owns-sneaker-42', + 'product.*', + ], +}; + +/** Literal found in a snippet, mapped to the `world.ph()` key that supplies the live value. */ +export const placeholders: Readonly> = { + replace_with_message_timetoken: 'messageTimetoken', + replace_with_action_timetoken: 'actionTimetoken', + replace_with_start_timetoken: 'actionsStart', + replace_with_end_timetoken: 'actionsEnd', + 'replace-with-start-timetoken': 'historyStart', + 'replace-with-end-timetoken': 'historyEnd', + 'replace-with-timetoken': 'countFrom', + // history (legacy): the docs use these literals as start and end anchors. + '13406746780720711': 'historyAnchor', + '123123123133': 'historyPromiseStart', + '123123123123': 'historyPromiseEnd', + 'replace-with-channel-timetoken-ch1': 'countFromCh1', + 'replace-with-channel-timetoken-ch2': 'countFromCh2', + 'replace-with-channel-timetoken-ch3': 'countFromCh3', + // File id and the image id of the React Native sample, both supplied by the step that sends the file. + '...': 'fileId', + imageId: 'imageId', + // Access Manager: a token the step granted, a token to revoke, the presence channel of my_channel. + 'use-token-string-generated-by-grantToken()': 'grantedToken', + p0AkFl043rhDdHRsple3KgQ3NwY6BDcENnctokenVzcqBDczaWdYIGOAeTyWGJI: 'tokenToRevoke', + 'my_channel-pnpres': 'myChannelPnpres', + // DataSync: eTags the docs show as examples. A step reads the real eTag first and sets these. + AbQdEfGhIjKlMn: 'userEtag', + StUvWxYzAbCdEf: 'entityEtag', +}; + +/** Names the harness creates itself. Used through `world.n()`, cleaned by the janitor. */ +export const harnessOnly: Readonly> = { + channel: ['cg1_member', 'channelGroup_1_member', 'daughter', 'filter_channel', 'persist_channel', 'preflight_channel', 'legacy_listeners_channel', 'am_room', 'am_other', 'push_marker'], + channelGroup: [], + userId: ['shopper', 'friend', 'observer', 'user-alice', 'user-bob', 'staff-server', 'am-revokee', 'am-v2-user', 'am-v2-reader', 'am-denied'], +}; + +/** `RUN_ID_PATTERN` validates a bare run id. `SUFFIX_PATTERN` finds the `-r` suffix inside a resource name (at the end or before a dot). */ +export const RUN_ID_PATTERN = /^[0-9a-z]{6}$/; +export const SUFFIX_PATTERN = /-r[0-9a-z]{6}(?=\.|$)/; + +/** + * True only when a name carries a run suffix that decodes to a run of the last year. The pattern alone also matches ordinary + * words (`test-results`, `app-reviews`), so the janitors use this before they delete anything on a shared keyset. + */ +export function hasRunSuffix(name: string): boolean { + const match = SUFFIX_PATTERN.exec(name); + return Boolean(match && runIdAgeMs(match[0].slice(2)) !== undefined); +} + +/** + * Per-run device token of the same length as `literal` and still valid hex (lowercase 0-9a-f). Derived from + * SHA-256 of the literal and the run id, so it is deterministic for one run (the janitor recomputes it for a crashed + * run from the run id alone) and different between runs. Plain "-r" suffixes would not be hex, and the + * service rejects non-hex tokens on apns and apns2. + */ +export function deviceToken(literal: string, runId: string): string { + let hex = ''; + for (let round = 0; hex.length < literal.length; round += 1) { + hex += createHash('sha256').update(`${literal}:${runId}:${round}`).digest('hex'); + } + return hex.slice(0, literal.length); +} + +/** + * Applies the run suffix: on the first dot segment when the name has dots, at the end otherwise. + * Device tokens are the exception: they get a derived hex token instead (see `deviceToken`). + */ +export function suffixName(name: string, runId: string): string { + if (names.device.includes(name)) return deviceToken(name, runId); + const suffix = `-r${runId}`; + const dot = name.indexOf('.'); + return dot === -1 ? `${name}${suffix}` : `${name.slice(0, dot)}${suffix}${name.slice(dot)}`; +} + +/** Every rewritable live-resource literal. */ +/** Used by the stage report (unused names) and by `isRegisteredName`. */ +export function allRewritableNames(): string[] { + return Object.values(names).flat(); +} + +/** Channel group names the janitor deletes per run id. */ +export function allGroupNames(): string[] { + return [...names.channelGroup, ...harnessOnly.channelGroup]; +} + +export function isRegisteredName(literal: string): boolean { + return allRewritableNames().includes(literal); +} + +export function isPlaceholder(literal: string): boolean { + return Object.prototype.hasOwnProperty.call(placeholders, literal); +} + +/** Literal that looks like a placeholder (so a missing registry entry is caught by the marker lint). */ +export const PLACEHOLDER_LOOKALIKE = /replace[-_]with/i; diff --git a/docs-snippets/fixtures/workdir.ts b/docs-snippets/fixtures/workdir.ts new file mode 100644 index 000000000..3dd20ee9f --- /dev/null +++ b/docs-snippets/fixtures/workdir.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Scratch directory for the file sharing samples. They read and write `./cat_picture.jpg` relative to the + * working directory, and `downloadFileNodeBasicUsage` overwrites it. The step changes into a temp directory that + * holds a copy of the fixture, so the tracked fixture stays untouched. + */ +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const FIXTURE_FILE = path.join(HERE, 'files', 'cat_picture.jpg'); + +export interface Workdir { + dir: string; + restore(): void; +} + +/** Copies the fixture to a temp dir and `chdir`s into it. The caller must `restore()` (in a `finally`), it is process-wide state. */ +export function enterWorkdir(): Workdir { + const previous = process.cwd(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pn-docs-files-')); + fs.copyFileSync(FIXTURE_FILE, path.join(dir, 'cat_picture.jpg')); + process.chdir(dir); + return { + dir, + restore() { + process.chdir(previous); + fs.rmSync(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/docs-snippets/getting-started/logging-file-logger.ts b/docs-snippets/getting-started/logging-file-logger.ts new file mode 100644 index 000000000..4f1978b41 --- /dev/null +++ b/docs-snippets/getting-started/logging-file-logger.ts @@ -0,0 +1,98 @@ +// snippet.loggingFileLoggerImports +import PubNub, { Logger, LogMessage } from 'pubnub' +import * as fs from 'fs'; + +// snippet.end +import assert from '../harness/assert.js'; +import os from 'node:os'; +import path from 'node:path'; +import { page, step } from '../harness/step.js'; +import { eventually } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/logging.md, "File logger" (Node.js). A module of its own because the sample's imports + * (`import * as fs from 'fs'` and the Logger types) would clash with the imports of getting-started/logging.ts. + * + * The page shows the import snippet `loggingFileLoggerImports` followed by `loggingFileLogger`. The sample reads the level + * name from `PubNub.LogLevel`: the SDK is a CommonJS package, so `import { LogLevel } from 'pubnub'` (what the page + * showed until 2026-11-03) fails in an ES module project ("does not provide an export named 'LogLevel'"). `Logger` and + * `LogMessage` are types only, the compiler removes them, so they are fine as named imports. + */ +export default page('logging', 'P0', (world) => { + // Changes into a temp directory because the sample writes `./pubnub.log`. The previous directory is restored in `finally`. + // The file is read back with `eventually` because the write stream flushes asynchronously. + step('loggingFileLogger', { proves: 'A file logger writes the SDK log lines to the log file.' }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pn-logging-')); + const previous = process.cwd(); + process.chdir(dir); + const quiet = world.captureConsole('log'); + try { + // snippet.loggingFileLogger + class FileLogger implements Logger { + private readonly stream: fs.WriteStream; + + constructor(private readonly logFilePath: string) { + this.stream = fs.createWriteStream(logFilePath, { flags: 'a' }); + } + + log(logMessage: LogMessage) { + const level = PubNub.LogLevel[logMessage.level]; + const timestamp = logMessage.timestamp.toISOString(); + const location = logMessage.location || 'Unknown'; + const message = this.formatMessage(logMessage); + + this.stream.write( + `${timestamp} [${level.toUpperCase()}] ${location}: ${message}\n` + ); + } + + formatMessage(logMessage: LogMessage) { + if (logMessage.messageType === 'text') { + return logMessage.message; + } else if (logMessage.messageType === 'object') { + return `${logMessage.details || ''}\n${JSON.stringify(logMessage.message, null, 2)}`; + } else if (logMessage.messageType === 'error') { + return `${logMessage.message.name}: ${logMessage.message.message}`; + } + // Handle other message types + return JSON.stringify(logMessage.message); + } + + trace(logMessage: LogMessage) { this.log(logMessage); } + debug(logMessage: LogMessage) { this.log(logMessage); } + info(logMessage: LogMessage) { this.log(logMessage); } + warn(logMessage: LogMessage) { this.log(logMessage); } + error(logMessage: LogMessage) { this.log(logMessage); } + } + + // Use FileLogger with PubNub + const pubnub = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + userId: 'myUniqueUserId', + logLevel: PubNub.LogLevel.Debug, + // snippet.hide + ...world.live.openKeys, + // snippet.show + loggers: [new FileLogger('./pubnub.log')] + }); + // snippet.end + world.track(pubnub); + world.check.serverTime((await pubnub.time()).timetoken); + const content = await eventually(() => { + const text = fs.readFileSync(path.join(dir, 'pubnub.log'), 'utf8'); + assert.match(text, /\[DEBUG\]/, 'debug entries are in the file'); + return text; + }, { label: 'file has log entries' }); + for (const line of content.split('\n').filter((l) => /^\d{4}-\d{2}-\d{2}T/.test(l))) { + assert.match(line, /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[(TRACE|DEBUG|INFO|WARN|ERROR)\] \S+: /); + } + assert.ok(/network|GET|\/time\//i.test(content), 'the time() request appears in the log'); + } finally { + quiet.restore(); + process.chdir(previous); + // The Debug log holds the SDK configuration, keys included. It must not outlive the step. + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/docs-snippets/getting-started/logging.ts b/docs-snippets/getting-started/logging.ts new file mode 100644 index 000000000..5ef4d3dae --- /dev/null +++ b/docs-snippets/getting-started/logging.ts @@ -0,0 +1,403 @@ +// snippet.loggingImportLogger +import { Logger, LogMessage } from 'pubnub' + +// snippet.end +import assert from '../harness/assert.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +// snippet.loggingImportPubNub +import PubNub from 'pubnub'; + +// snippet.end +import { page, step, check } from '../harness/step.js'; +import { eventually } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/logging.md + * + * P0. Every sample of the page is hardcoded, so the ids below are proposed ids, the docs page does not embed them yet. + * The docs samples start with import lines. A step body cannot hold an import, so `loggingImportPubNub` and + * `loggingImportLogger` are module-level import-only snippets, and the page shows each sample as import snippet plus body. + * The file logger sample has imports of its own and lives in getting-started/logging-file-logger.ts. + * + * Open keyset, phase P0. In the Sneaker Drop story this is how the shop's engineers watch the client in production. The four + * steps run the page's samples (basic usage, custom logger, several loggers, file logger) and the checks prove the prose and + * the `LogMessage` reference: level filtering, console format, the five message types and where failed requests are logged. + * Deliberately not proven: logging in a browser, and the exact text of messages (it changes between SDK versions, so the + * checks match shapes and levels). + */ +const LogLevel = PubNub.LogLevel; + +export default page('logging', 'P0', (world) => { + /** Records what a logger receives, per level and per message type. */ + const recorder = () => { + const seen: Array<{ level: string; message: LogMessage }> = []; + const logger: Logger = { + trace: (m) => seen.push({ level: 'trace', message: m }), + debug: (m) => seen.push({ level: 'debug', message: m }), + info: (m) => seen.push({ level: 'info', message: m }), + warn: (m) => seen.push({ level: 'warn', message: m }), + error: (m) => seen.push({ level: 'error', message: m }), + }; + return { seen, logger }; + }; + + // Debug level makes the SDK's console logger print its configuration, which holds the keys. The capture swallows that output + // while the client is built and is restored right after. redact.cjs masks keys in the run log as a second line of defense. + step('loggingBasicUsage', { proves: 'A client built with Debug level logging works, and its logger output is captured.' }, async () => { + const quiet = world.captureConsole('info'); + // snippet.loggingBasicUsage + // Initialize PubNub with debug-level logging + const pubnub = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + userId: 'myUniqueUserId', + // snippet.hide + ...world.live.openKeys, + // snippet.show + logLevel: PubNub.LogLevel.Debug + }); + // snippet.end + world.track(pubnub); + quiet.restore(); + assert.equal(pubnub.getUserId(), world.n('myUniqueUserId')); + assert.ok(quiet.lines.length >= 0); + }); + + // The page assumes `monitoringService` and `analyticsService` exist. They are stand-ins that collect the payloads so the step can + // assert what the sample would report. Runs the sample against an unreachable origin to force an error entry. + // `reportNetworkFailure` fires because the sample handles `network-request` in `warn()` (the SDK logs a failed request at warn). + step('loggingCustomLoggerExample', { proves: 'A custom logger receives the error entry and the sample reports it to the stand-in services.' }, async () => { + // "The example assumes that monitoringService and analyticsService are objects in your application." + const reported = { errors: [] as any[], failures: [] as any[], deprecations: [] as any[], configs: [] as any[] }; + const monitoringService = { + reportError: (payload: any) => reported.errors.push(payload), + reportNetworkFailure: (payload: any) => reported.failures.push(payload), + }; + const analyticsService = { + trackDeprecation: (payload: any) => reported.deprecations.push(payload), + trackConfigChange: (payload: any) => reported.configs.push(payload), + }; + const quiet = world.captureConsole('log'); + // snippet.loggingCustomLoggerExample + class MonitoringLogger implements Logger { + /** + * Process error-level messages + */ + error(logMessage: LogMessage) { + const { timestamp, pubNubId, location, messageType } = logMessage; + + if (messageType === 'error') { + // TypeScript narrows type to ErrorLogMessage + // IDE provides autocomplete for PubNubError fields + monitoringService.reportError({ + message: logMessage.message.message, + name: logMessage.message.name, + stack: logMessage.message.stack, + category: logMessage.message.status?.category, + operation: logMessage.message.status?.operation, + instanceId: pubNubId, + timestamp: timestamp.toISOString(), + location + }); + } + } + + /** + * Process warning messages and failed requests + */ + warn(logMessage: LogMessage) { + if (logMessage.messageType === 'text') { + // TypeScript narrows type to TextLogMessage + // IDE provides autocomplete for string methods on message + if (logMessage.message.includes('deprecated')) { + analyticsService.trackDeprecation({ + warning: logMessage.message, + instanceId: logMessage.pubNubId, + timestamp: logMessage.timestamp.toISOString() + }); + } + } else if (logMessage.messageType === 'network-request') { + // TypeScript narrows type to NetworkRequestLogMessage + // The SDK logs a failed request at the warn level + if (logMessage.failed) { + const url = `${logMessage.message.origin}${logMessage.message.path}`; + + monitoringService.reportNetworkFailure({ + url: url, + method: logMessage.message.method, + reason: logMessage.details, + instanceId: logMessage.pubNubId, + timestamp: logMessage.timestamp.toISOString() + }); + } + } + } + + /** + * Process debug messages for configuration tracking + */ + debug(logMessage: LogMessage) { + if (logMessage.messageType === 'object') { + // TypeScript narrows type to ObjectLogMessage + // IDE provides autocomplete for details and ignoredKeys fields + if (logMessage.details?.includes('configuration')) { + analyticsService.trackConfigChange({ + config: logMessage.message, + instanceId: logMessage.pubNubId, + timestamp: logMessage.timestamp.toISOString() + }); + } + } + } + + // No-op implementations for other levels if not needed + trace() {} + info() {} + } + + // Configure SDK with custom logger + const pubnub = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + userId: 'myUniqueUserId', + logLevel: PubNub.LogLevel.Debug, + // snippet.hide + ...world.live.openKeys, ...world.unreachable, + // snippet.show + loggers: [new MonitoringLogger()] + }); + // snippet.end + world.track(pubnub); + quiet.restore(); + + // Debug entry about the configuration reaches trackConfigChange. + assert.ok(reported.configs.length > 0, 'trackConfigChange received the configuration entry'); + assert.ok(reported.configs[0].instanceId, 'instanceId (pubNubId) is set'); + assert.match(reported.configs[0].timestamp, /^\d{4}-\d{2}-\d{2}T/, 'timestamp is an ISO string'); + + // The client points at a port where nothing listens, so a request fails. The failure reaches reportError (error entry) + // and reportNetworkFailure (failed network-request entry). The deprecated stop() reaches trackDeprecation (warn entry). + await assert.rejects(pubnub.publish({ channel: 'x', message: 'm' })); + await eventually(() => assert.ok(reported.errors.length > 0, 'reportError received an error entry'), { label: 'error entry' }); + assert.equal(reported.errors[0].name, 'PubNubError'); + assert.ok(reported.errors[0].message); + assert.equal(reported.errors[0].category, 'PNNetworkIssuesCategory'); + assert.equal(reported.errors[0].operation, 'PNPublishOperation'); + assert.ok(reported.errors[0].stack, 'stack is set'); + // The SDK logs a failed network-request at the warn level, and the sample handles it in warn(), so + // reportNetworkFailure receives the failed publish request. + await eventually(() => assert.ok(reported.failures.length > 0, 'reportNetworkFailure received a failed request'), { label: 'failed request entry' }); + assert.ok(reported.failures[0].method, 'method is set'); + assert.match(reported.failures[0].url, /\/publish\//, 'the url names the publish path'); + assert.ok(reported.failures[0].reason, 'reason (details) is set'); + assert.ok(reported.failures[0].instanceId, 'instanceId (pubNubId) is set'); + pubnub.stop(); + assert.ok(reported.deprecations.length > 0, 'a deprecation warning reaches trackDeprecation'); + assert.match(reported.deprecations[0].warning, /deprecated/); + }); + + // The sample refers to loggers defined elsewhere on the page, so the step defines empty ones plus a small file logger to prove + // the SDK calls every logger in the array. + step('loggingMultipleLoggers', { proves: 'The SDK calls every logger in the loggers array.' }, async () => { + // The page defines MonitoringLogger, AnalyticsLogger and FileLogger in the samples before this one. + class MonitoringLogger implements Logger { trace() {} debug() {} info() {} warn() {} error() {} } + class AnalyticsLogger implements Logger { trace() {} debug() {} info() {} warn() {} error() {} } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pn-logging-')); + const file = path.join(dir, 'multi.log'); + class FileLogger implements Logger { + private readonly stream = fs.createWriteStream(file, { flags: 'a' }); + trace(m: LogMessage) { this.stream.write(`${m.level}\n`); } + debug(m: LogMessage) { this.stream.write(`${m.level}\n`); } + info(m: LogMessage) { this.stream.write(`${m.level}\n`); } + warn(m: LogMessage) { this.stream.write(`${m.level}\n`); } + error(m: LogMessage) { this.stream.write(`${m.level}\n`); } + } + const quiet = world.captureConsole('log'); + // snippet.loggingMultipleLoggers + const pubnub = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + userId: 'myUniqueUserId', + logLevel: PubNub.LogLevel.Debug, + // snippet.hide + ...world.live.openKeys, + // snippet.show + loggers: [ + new MonitoringLogger(), // Send errors to monitoring service + new AnalyticsLogger(), // Track usage patterns + new FileLogger() // Write detailed logs to file (Node.js) + ] + }); + // snippet.end + world.track(pubnub); + quiet.restore(); + try { + // Each custom logger is called next to the console logger: the file logger saw the initialization entries. + await eventually(() => assert.ok(fs.readFileSync(file, 'utf8').length > 0), { label: 'file logger wrote' }); + } finally { + pubnub.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + // The file logger sample lives in getting-started/logging-file-logger.ts: its imports (`import * as fs`, the Logger types) + // clash with this module's, and an import cannot sit inside a step. + + // ---- the prose and the LogMessage reference ------------------------------------------------------------------ + + // Table-driven: for each level the set of entry levels a recorder logger sees must be a subset of the allowed ones, and every + // allowed level up to debug must appear. The `publish` with an empty channel forces an error entry, `stop()` forces a warn + // (deprecated). Also proves the default level is None. + check('loggingLevels', { timeout: 60_000, proves: 'Each log level records only the entry levels it should, and the default level is None.' }, async () => { + const open = world.env.open!; + // The same traffic at every level. A level records its own entries and those of the less verbose levels listed after it. + const levels: Array<[string, PubNub.LogLevel, string[]]> = [ + ['trace', LogLevel.Trace, ['trace', 'debug', 'info', 'warn', 'error']], + ['debug', LogLevel.Debug, ['debug', 'info', 'warn', 'error']], + ['info', LogLevel.Info, ['info', 'warn', 'error']], + ['warn', LogLevel.Warn, ['warn', 'error']], + ['error', LogLevel.Error, ['error']], + ['none', LogLevel.None, []], + ]; + const seenByLevel = new Map>(); + for (const [name, logLevel, allowed] of levels) { + const { logger, seen } = recorder(); + const quiet = world.captureConsole('log'); + const infoQuiet = world.captureConsole('info'); + const warnQuiet = world.captureConsole('warn'); + try { + const pubnub = world.track(new PubNub({ ...open, userId: world.n(`logging-${name}`), logLevel, loggers: [logger] })); + await pubnub.time(); + await assert.rejects(pubnub.publish({ channel: '', message: 'x' } as unknown as PubNub.Publish.PublishParameters)); + pubnub.stop(); // warn: deprecated + } finally { + quiet.restore(); + infoQuiet.restore(); + warnQuiet.restore(); + } + const levelsSeen = new Set(seen.map((s) => s.level)); + for (const level of levelsSeen) assert.ok(allowed.includes(level), `logLevel ${name} must not record ${level} entries`); + for (const level of ['error', 'warn', 'info', 'debug'].filter((l) => allowed.includes(l))) { + assert.ok(levelsSeen.has(level), `logLevel ${name} records ${level} entries (saw ${[...levelsSeen]})`); + } + seenByLevel.set(name, levelsSeen); + } + assert.equal(seenByLevel.get('none')!.size, 0, 'LogLevel.None records nothing'); + // Default: logging is disabled. + const { logger, seen } = recorder(); + world.track(new PubNub({ ...open, userId: world.n('logging-default'), loggers: [logger] })); + assert.equal(seen.length, 0, 'the default logLevel is None'); + }); + + // Replaces the console methods by hand (not `captureConsole`) because the check needs all five at once to prove Trace goes to `console.debug`. + check('loggingConsoleLogger', { proves: 'The console logger writes each level to the matching console method, with Trace going to console.debug.' }, async () => { + // The built-in console logger runs next to custom loggers and prints ISO timestamp, PubNub-, level, location, message. + const original = { log: console.log, info: console.info, debug: console.debug, warn: console.warn, error: console.error }; + const lines: Array<{ method: string; text: string }> = []; + for (const method of ['log', 'info', 'debug', 'warn', 'error'] as const) { + console[method] = (...args: unknown[]) => lines.push({ method, text: args.map(String).join(' ') }); + } + try { + const open = world.env.open!; + const pubnub = world.track(new PubNub({ ...open, userId: world.n('logging-console'), logLevel: LogLevel.Trace })); + await pubnub.time(); + pubnub.stop(); + } finally { + Object.assign(console, original); + } + assert.ok(lines.length > 0, 'the console logger printed'); + const format = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z PubNub-[0-9a-f]+ (Trace|Debug|Info|Warn|Error)\s+\S+ /; + assert.ok(lines.some((l) => format.test(l.text)), `an entry matches the documented format. First: ${lines[0]?.text.slice(0, 120)}`); + assert.ok(lines.filter((l) => / Trace /.test(l.text)).every((l) => l.method === 'debug'), 'Trace entries are written with console.debug'); + }); + + // Asserts the shape of every `LogMessage` type from the page's reference. Uses an unreachable client as well to get a failed + // `network-request` entry. + check('loggingMessageTypes', { timeout: 60_000, proves: 'Every log message type has the shape the page reference lists.' }, async () => { + const { logger, seen } = recorder(); + const quiet = world.captureConsole('log'); + const debugQuiet = world.captureConsole('info'); + try { + const pubnub = world.track(new PubNub({ ...world.env.open!, userId: world.n('logging-types'), logLevel: LogLevel.Trace, loggers: [logger] })); + await pubnub.time(); + // Validation failures are logged as a plain status object (live finding), so use an unreachable origin to get a PubNubError. + const unreachable = world.track(new PubNub({ ...world.env.open!, ...world.unreachable, userId: world.n('logging-types-down'), logLevel: LogLevel.Trace, loggers: [logger] })); + await assert.rejects(unreachable.publish({ channel: 'x', message: 'm' })); + } finally { + quiet.restore(); + debugQuiet.restore(); + } + const byType = (type: string) => seen.map((s) => s.message).filter((m) => m.messageType === type); + // Every entry carries the BaseLogMessage fields. + for (const { message, level } of seen) { + assert.ok(message.timestamp instanceof Date, 'timestamp is a Date'); + assert.match(message.pubNubId, /^[0-9a-f]+$/); + assert.equal(typeof message.level, 'number'); + assert.equal(message.minimumLevel, LogLevel.Trace); + assert.ok(['text', 'object', 'error', 'network-request', 'network-response'].includes(message.messageType)); + assert.ok(level); + } + const text = byType('text')[0] as Extract; + assert.equal(typeof text.message, 'string'); + const object = byType('object')[0] as Extract; + assert.ok(object.message !== undefined); + const error = byType('error')[0] as Extract; + assert.ok(error, 'an error entry from the failed publish'); + assert.ok(error.message.name && error.message.message, 'error entry has name and message'); + assert.ok(error.message.stack, 'error entry has a stack'); + assert.equal(error.message.status?.category, 'PNNetworkIssuesCategory', 'error entry has status.category'); + const failed = byType('network-request').find((m) => (m as { failed?: boolean }).failed) as Extract | undefined; + assert.ok(failed, 'a network-request entry with failed: true for the unreachable origin'); + assert.ok(!failed.canceled); + const request = byType('network-request').find((m) => /pndsn\.com/.test((m as { message: { origin?: string } }).message.origin ?? '')) as Extract; + assert.ok(request, 'a network-request entry from time()'); + assert.equal(request.message.method, 'GET'); + assert.match(request.message.origin ?? '', /pndsn\.com/); + assert.match(request.message.path, /\/time\//); + assert.ok(request.message.queryParameters, 'queryParameters'); + const response = byType('network-response')[0] as Extract; + assert.ok(response, 'a network-response entry from time()'); + assert.equal(response.message.status, 200); + assert.match(response.message.url, /\/time\//); + assert.ok(response.message.headers, 'headers'); + assert.ok(response.message.body instanceof ArrayBuffer, 'the body is an ArrayBuffer, as the page shows'); + // Node.js: the details field carries a protocol hint. + assert.match(String(response.details ?? ''), /Negotiated protocol: \S+/, 'network-response details carry the negotiated protocol on Node.js'); + console.log(`first error entry: ${JSON.stringify({ ...error, timestamp: undefined }).slice(0, 300)}`); + }); + + // The page shows ErrorLogMessage.message as a PubNubError (name, message, stack, status). A validation failure is logged + // as a plain status object instead (live finding). This check records the current shape. + check('loggingValidationErrorEntryShape', { proves: 'Records the shape of a logged validation failure, a plain status object and not a PubNubError.' }, () => { + const { logger, seen } = recorder(); + const pubnub = world.track(new PubNub({ ...world.env.open!, userId: world.n('logging-validation'), logLevel: LogLevel.Error, loggers: [logger] })); + return assert.rejects(pubnub.publish({ channel: '', message: 'x' } as unknown as PubNub.Publish.PublishParameters)).then(() => { + const entry = seen.find((s) => s.message.messageType === 'error')!.message as Extract; + const message = entry.message as unknown as Record; + assert.equal(message.message, "Missing 'channel'"); + assert.equal(message.category, 'PNValidationErrorCategory'); + assert.equal(message.name, undefined, 'no name on the logged validation failure'); + assert.equal(message.stack, undefined, 'no stack on the logged validation failure'); + assert.equal(message.status, undefined, 'category sits on the message itself, not under status'); + }); + }); + + // The sample reads failed and canceled requests in error(). Record the level at which the SDK logs them. + check('loggingFailedRequestLevel', { proves: 'Records the log level of a failed request, which is warn.' }, async () => { + const { logger, seen } = recorder(); + const quiet = world.captureConsole('log'); + try { + const down = world.track(new PubNub({ ...world.env.open!, ...world.unreachable, userId: world.n('logging-failed'), logLevel: LogLevel.Trace, loggers: [logger] })); + await assert.rejects(down.publish({ channel: 'x', message: 'm' })); + } finally { + quiet.restore(); + } + const failed = seen.filter((s) => s.message.messageType === 'network-request' && (s.message as { failed?: boolean }).failed); + assert.equal(failed.length, 1); + assert.equal(failed[0].level, 'warn', 'a failed request is logged at the warn level, not error'); + assert.equal((failed[0].message as { details?: string }).details, 'Network error'); + assert.ok(!seen.some((s) => s.level === 'error' && s.message.messageType === 'network-request'), 'error() receives no network-request entries'); + }); +}); diff --git a/docs-snippets/getting-started/quickstart-node.mjs b/docs-snippets/getting-started/quickstart-node.mjs new file mode 100644 index 000000000..c505e731b --- /dev/null +++ b/docs-snippets/getting-started/quickstart-node.mjs @@ -0,0 +1,44 @@ +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +pubnub.addListener({ + message: (event) => { + console.log(`Received on ${event.channel}: ${event.message}`); + }, + status: (event) => { + if (event.category === 'PNConnectedCategory') { + console.log('Connected to PubNub'); + } + }, +}); + +// Create a channel entity +const channel = pubnub.channel('hello_world'); + +// Create a subscription +const subscription = channel.subscription({ + receivePresenceEvents: true, // to receive presence events +}); + +// Subscribe +subscription.subscribe(); + +pubnub.addListener({ + status: async (event) => { + if (event.category !== 'PNConnectedCategory') return; + try { + const result = await pubnub.publish({ + channel: 'hello_world', + message: 'Hello, world!', + }); + console.log(`Published, timetoken: ${result.timetoken}`); + } catch (error) { + console.error(`Publish failed: ${error}`); + } + }, +}); diff --git a/docs-snippets/getting-started/quickstart-node.ts b/docs-snippets/getting-started/quickstart-node.ts new file mode 100644 index 000000000..908275001 --- /dev/null +++ b/docs-snippets/getting-started/quickstart-node.ts @@ -0,0 +1,152 @@ +import assert from '../harness/assert.js'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { eventually } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/quickstart-node.md + * + * P4. The page walks through one script: import, initialize, listeners, subscription, publish. The first step runs those + * snippets in the order of the page in one scope, the way a reader pastes them into `index.mjs`, and the client publishes and + * receives its own message. The second step runs the complete `quickstart-node.mjs` file from the "Run" section in a real + * Node.js process and reads its output. The `importPubNub` import is in `shared/import-pubnub.ts`. + * + * `quickstartNodeListeners` is hardcoded on the docs page today. `quickstartNodePublish` holds the status listener that + * publishes once the client is connected, with the `try` and `catch` of the docs body. + * + * Open keyset. In the Sneaker Drop story this is the first script a new developer runs: connect, listen on `hello_world`, say + * hello. Proves the four step-by-step snippets work in page order in one scope, that the user id and channel placeholders are + * what the page says, and that the complete `quickstart-node.mjs` runs as a real Node.js child process and prints the three + * documented lines. `quickstart-node.mjs` carries no comments on purpose, it is the exact text the docs show. + * The script publishes after the `PNConnectedCategory` status, so the own message is asserted. + */ +export default page('getting-started/quickstart-node', 'P4', (world) => { + const HERE = path.dirname(fileURLToPath(import.meta.url)); + + // One step, four snippet ids, because the page reads as one script. `world.withConsole` captures `console.log` so the lines the + // page promises ("Connected to PubNub", "Published, timetoken: ...", "Received on ...") can be asserted. The snippets stay in + // one function scope, so `pubnub` and `subscription` are visible to the later snippets exactly as on the page. + step('gettingStartedInitPubnub', { snippets: ['gettingStartedInitPubnub', 'quickstartNodeListeners', 'gettingStartedCreateSubscription', 'quickstartNodePublish'], proves: 'The Node quickstart script connects, publishes and receives, and prints the lines the page promises.' }, async () => { + await world.withConsole(async (logs) => { + // snippet.gettingStartedInitPubnub + const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', + // snippet.hide + ...world.live.open, + // snippet.show + }); + // snippet.end + world.track(pubnub); + + // snippet.quickstartNodeListeners + pubnub.addListener({ + message: (event) => { + console.log(`Received on ${event.channel}: ${event.message}`); + }, + status: (event) => { + if (event.category === 'PNConnectedCategory') { + console.log('Connected to PubNub'); + } + }, + }); + // snippet.end + + // snippet.gettingStartedCreateSubscription + // Create a channel entity + const channel = pubnub.channel('hello_world'); + + // Create a subscription + const subscription = channel.subscription({ + receivePresenceEvents: true, // to receive presence events + }); + + // Subscribe + subscription.subscribe(); + // snippet.end + + // snippet.quickstartNodePublish + pubnub.addListener({ + status: async (event) => { + if (event.category !== 'PNConnectedCategory') return; + try { + const result = await pubnub.publish({ + channel: 'hello_world', + message: 'Hello, world!', + }); + console.log(`Published, timetoken: ${result.timetoken}`); + } catch (error) { + console.error(`Publish failed: ${error}`); + } + }, + }); + // snippet.end + + assert.equal(pubnub.getUserId(), world.n('YOUR_USER_ID')); + await eventually(() => assert.match(logs.lines.find((l) => l.startsWith('Published, timetoken: ')) ?? '', /^Published, timetoken: \d{17}$/), { label: 'published line printed' }); + await eventually(() => assert.ok(logs.lines.includes(`Received on ${world.n('hello_world')}: Hello, world!`)), { label: 'own message received' }); + subscription.unsubscribe(); + }); + }); + + // Why the placeholder replacement is done with `replace`: the file is the docs text with placeholders, so it is never edited + // on disk. The child process is registered with `world.trackChild`, so a killed run does not leave a subscribed process behind. + // The complete file of the "Run" section, executed with Node.js. Placeholders get the live keys and run-suffixed names. + check('quickstartNodeRun', { timeout: 60_000, proves: 'The complete quickstart file runs in a Node process, prints Connected and Published, receives its own message, and receives a message from another client.' }, async () => { + const open = world.env.open!; + const source = fs + .readFileSync(path.join(HERE, 'quickstart-node.mjs'), 'utf8') + .replace('YOUR_PUBLISH_KEY', open.publishKey) + .replace('YOUR_SUBSCRIBE_KEY', open.subscribeKey) + .replace('YOUR_USER_ID', world.n('YOUR_USER_ID')) + .replaceAll("'hello_world'", `'${world.n('hello_world')}'`); + // The file sits next to the app so that `import 'pubnub'` resolves the same installation. + const dir = fs.mkdtempSync(path.join(HERE, '.quickstart-')); + const file = path.join(dir, 'index.mjs'); + fs.writeFileSync(file, source); + try { + const child = world.trackChild(spawn(process.execPath, [file], { stdio: ['ignore', 'pipe', 'pipe'] })); + let out = ''; + let err = ''; + child.stdout.on('data', (chunk) => (out += chunk)); + child.stderr.on('data', (chunk) => (err += chunk)); + try { + // The script publishes only after the connected status, so its own message reaches an active subscription and the + // "Received" line is deterministic. All three documented lines are asserted. + await eventually( + () => { + const lines = out.split('\n'); + assert.ok(lines.includes('Connected to PubNub'), `Connected line. Output: ${out} ${err}`); + assert.ok(lines.some((l) => /^Published, timetoken: \d{17}$/.test(l)), `Published line. Output: ${out} ${err}`); + assert.ok(lines.includes(`Received on ${world.n('hello_world')}: Hello, world!`), `Received line. Output: ${out} ${err}`); + }, + { timeout: 30_000, label: 'quickstart script connected, published and received its own message' }, + ); + // The listener keeps working: a message from another client arrives too. + await world.friend.publish({ channel: world.n('hello_world'), message: 'Hello from the harness' }); + await eventually( + () => assert.ok(out.split('\n').includes(`Received on ${world.n('hello_world')}: Hello from the harness`), `Received line. Output: ${out} ${err}`), + { timeout: 30_000, label: 'quickstart script receives a message from another client' }, + ); + // "The script keeps running because the subscription stays open." + assert.equal(child.exitCode, null, 'the script keeps running'); + } finally { + child.kill('SIGINT'); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + // The "Before you begin" claim: Node.js 22 or later. The SDK declares the same minimum. + check('quickstartNodeVersion', { proves: 'The SDK declares the same minimum Node.js version as the quickstart states.' }, () => { + const engines = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.resolve('pubnub'))), '..', '..', 'package.json'), 'utf8')).engines; + assert.equal(engines.node, '>=22.0.0'); + assert.ok(Number(process.versions.node.split('.')[0]) >= 22, 'the app itself runs on Node.js 22 or later'); + }); +}); diff --git a/docs-snippets/getting-started/set-up-your-environment.ts b/docs-snippets/getting-started/set-up-your-environment.ts new file mode 100644 index 000000000..fbd854f77 --- /dev/null +++ b/docs-snippets/getting-started/set-up-your-environment.ts @@ -0,0 +1,191 @@ +import assert from '../harness/assert.js'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +// snippet.setUpTypeScriptImport +import PubNub from 'pubnub'; + +// snippet.end +import { page, step, check } from '../harness/step.js'; + +/** + * docs/sdks/javascript/set-up-your-environment.md + * + * P0. Most of the page is shell commands, HTML and prose. What can run headless runs here: the TypeScript sample and + * its `tsconfig.json` (compiled with the real `tsc`), the module format claims (ESM, CommonJS, `.mjs`, `"type": "module"`), + * the build module table against `rollup.config.js`, the CDN URLs, and the Webpack configuration (syntax only). + * The shared worker sample needs a browser and is type-checked only. + * + * `setUpTypeScriptInit` is hardcoded on the docs page today, so that id is a proposed id, the docs page does not embed it yet. The import + * line sits outside the snippet, because a step body cannot hold an import (`importPubNub` embeds it). + * + * Open keyset for the one live step, everything else needs no keys. In the Sneaker Drop story this is the developer's laptop + * being prepared: toolchain, module format, bundle size options. Proves what the page claims about installation and build: + * the TypeScript sample compiles under strict settings, the three module formats load the SDK, the documented build variables + * equal the SDK's `rollup.config.js` and the CDN files exist. Does not prove a Webpack build (the configuration is only parsed), + * npm installs, shell commands or the shared worker (needs a browser). + */ +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SDK_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('pubnub'))), '..', '..'); +const TSC = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('typescript'))), 'tsc.js'); + +export default page('getting-started/set-up-your-environment', 'P0', (world) => { + /** A scratch directory below the staged suite, so `pubnub` resolves from the suite's node_modules. */ + const scratch = (): string => fs.mkdtempSync(path.join(HERE, '.setup-')); + + // The only live step: the TypeScript init snippet with real keys. The hidden spread supplies them, `world.n` shows the staging + // rewrite renamed the user id, and `time()` proves the keys work. + step('setUpTypeScriptInit', { proves: 'The TypeScript init sample builds a working client and time() succeeds.' }, async () => { + // snippet.setUpTypeScriptInit + const pubnub = new PubNub({ + publishKey: 'your-publish-key', + subscribeKey: 'your-subscribe-key', + // snippet.hide + ...world.live.open, + // snippet.show + userId: 'your-user-id' + }); + // snippet.end + world.track(pubnub); + assert.equal(pubnub.getUserId(), world.n('your-user-id')); + world.check.serverTime((await pubnub.time()).timetoken); + }); + + // The whole TypeScript walk-through: the sample file and the documented tsconfig.json compile under strict settings. + check('setUpTypeScriptProject', { timeout: 90_000, proves: 'The TypeScript sample file and the documented tsconfig.json compile under strict settings.' }, () => { + const dir = scratch(); + try { + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + }, + include: ['index.ts'], + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(dir, 'index.ts'), + `import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'your-publish-key', + subscribeKey: 'your-subscribe-key', + userId: 'your-user-id' +}); +`, + ); + // "you do not need an additional @types package": tsc resolves the types that the pubnub package ships. + execFileSync(process.execPath, [TSC, '-p', dir, '--noEmit'], { stdio: 'pipe' }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + // Each format runs in a real Node.js process in a scratch directory below the suite, so `pubnub` resolves from the suite's + // node_modules. The probe constructs a client with a dummy subscribe key only, it makes no request. + check('setUpModuleFormats', { timeout: 60_000, proves: 'Each documented module format loads the SDK in a real Node process.' }, () => { + const dir = scratch(); + const run = (args: string[], cwd = dir): string => execFileSync(process.execPath, args, { cwd, encoding: 'utf8', stdio: 'pipe' }).trim(); + const probe = "typeof PubNub === 'function' ? new PubNub({ subscribeKey: 'k', userId: 'u' }).getUserId() : 'no constructor'"; + try { + // CommonJS: const PubNub = require('pubnub'); + fs.writeFileSync(path.join(dir, 'cjs.cjs'), `const PubNub = require('pubnub');\nconsole.log(${probe});\n`); + assert.equal(run([path.join(dir, 'cjs.cjs')]), 'u', 'require("pubnub") returns the constructor'); + // ES modules through the .mjs extension. + fs.writeFileSync(path.join(dir, 'esm.mjs'), `import PubNub from 'pubnub';\nconsole.log(${probe});\n`); + assert.equal(run([path.join(dir, 'esm.mjs')]), 'u', '.mjs files use import'); + // "type": "module" in package.json makes .js an ES module. + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type: 'module' })); + fs.writeFileSync(path.join(dir, 'esm.js'), `import PubNub from 'pubnub';\nconsole.log(${probe});\n`); + assert.equal(run([path.join(dir, 'esm.js')]), 'u', '"type": "module" makes .js an ES module'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + // Two-way drift guard against the SDK checkout: every documented variable and default must be in rollup.config.js, and every + // `*_MODULE` variable the SDK source reads must be documented, so adding a module to the SDK without the table fails here. + // Build a custom bundle: the module table matches rollup.config.js, names and defaults. + check('setUpBundleModuleTable', { proves: 'The custom bundle module table matches the SDK rollup configuration in both directions.' }, () => { + const documented: Record = { + CRYPTO_MODULE: 'enabled', + SHARED_WORKER: 'enabled', + PUBLISH_MODULE: 'enabled', + SUBSCRIBE_MODULE: 'enabled', + SUBSCRIBE_EVENT_ENGINE_MODULE: 'enabled', + SUBSCRIBE_MANAGER_MODULE: 'enabled', + PRESENCE_MODULE: 'enabled', + PAM_MODULE: 'disabled', + CHANNEL_GROUPS_MODULE: 'enabled', + MESSAGE_PERSISTENCE_MODULE: 'enabled', + MOBILE_PUSH_MODULE: 'enabled', + APP_CONTEXT_MODULE: 'enabled', + DATA_SYNC_MODULE: 'enabled', + FILE_SHARING_MODULE: 'enabled', + MESSAGE_REACTIONS_MODULE: 'enabled', + }; + const rollup = fs.readFileSync(path.join(SDK_ROOT, 'rollup.config.js'), 'utf8'); + const actual: Record = {}; + for (const match of rollup.matchAll(/'process\.env\.([A-Z_]+)':\s*JSON\.stringify\(process\.env\.\1 \?\? '(enabled|disabled)'\)/g)) actual[match[1]] = match[2]; + assert.deepEqual(actual, documented, 'documented build variables and defaults equal rollup.config.js'); + // The page names the modules the SDK source actually checks. + const used = new Set(); + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts')) for (const m of fs.readFileSync(full, 'utf8').matchAll(/process\.env\.([A-Z_]+_MODULE|SHARED_WORKER)\b/g)) used.add(m[1]); + } + }; + walk(path.join(SDK_ROOT, 'src')); + assert.deepEqual([...used].sort(), Object.keys(documented).sort(), 'every build variable that the source reads is documented'); + // "Set enableTreeShaking to true": the constant exists. + assert.match(rollup, /const enableTreeShaking = (false|true);/); + }); + + // The Webpack configuration of the page: valid JavaScript, and it defines the variables of the table. + check('setUpWebpackConfigSyntax', { proves: 'The documented Webpack configuration is valid JavaScript and defines the variables of the table.' }, () => { + const source = `const webpack = require('webpack'); + +module.exports = { + // Other configuration... + plugins: [ + new webpack.DefinePlugin({ + 'process.env.PRESENCE_MODULE': JSON.stringify(process.env.PRESENCE_MODULE ?? 'enabled'), + 'process.env.PUBLISH_MODULE': JSON.stringify(process.env.PUBLISH_MODULE ?? 'enabled'), + // One entry for every variable in the table. + }), + ], +}; +`; + const result = ts.transpileModule(source, { reportDiagnostics: true, compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }); + assert.deepEqual(result.diagnostics ?? [], [], 'the Webpack configuration parses'); + }); + + // Load the SDK from a CDN: the pinned URLs of the current version exist. + // Network, but no keys: HEAD requests against the public CDN for the version of the linked SDK. Fails until that version is + // published, which is the point (the page pins the URL). + check('setUpCdnUrls', { timeout: 30_000, proves: 'The pinned CDN URLs for the linked SDK version exist.' }, async () => { + const version = JSON.parse(fs.readFileSync(path.join(SDK_ROOT, 'package.json'), 'utf8')).version as string; + for (const file of [`pubnub.${version}.js`, `pubnub.worker.${version}.js`]) { + const response = await fetch(`https://cdn.pubnub.com/sdk/javascript/${file}`, { method: 'HEAD' }); + assert.equal(response.status, 200, `${file} exists on the CDN`); + } + }); + + // The SharedWorker sample (configurationBasicUsageSubscriptionWorkerUrl) needs a browser. Its source is + // quickstarts/browser-shared-worker.ts. This repository has no browser, so it cannot run it. +}); diff --git a/docs-snippets/getting-started/status-events.ts b/docs-snippets/getting-started/status-events.ts new file mode 100644 index 000000000..856713b97 --- /dev/null +++ b/docs-snippets/getting-started/status-events.ts @@ -0,0 +1,380 @@ +import assert from '../harness/assert.js'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { connected, eventually, expectEvent, sleep } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/status-events.md + * + * The embedded `eventListenerAddConnectionStatusListenersBasicUsage` is a subscribe-page snippet (api-reference/subscribe.ts). + * This module covers the rest of the page: the before and after listener samples (P4), the request status categories + * against a scripted local origin (P0), and the subscription statuses under a network outage through the local CONNECT + * proxy (P7): `PNDisconnectedUnexpectedlyCategory`, `PNConnectionErrorCategory`, retry policies, and the legacy manager. + * + * The page hardcodes its two samples, so the ids `statusEventsMigrationBefore` and `statusEventsMigrationAfter` are + * proposed ids, the docs page does not embed them yet. Browser-only statuses stay out (`PNNetworkUpCategory`, + * `PNNetworkDownCategory`, `PNSharedWorkerUpdatedCategory`). + * + * Phase P7 for the page, with per-step overrides, open keyset (one step uses the secured one). In the Sneaker Drop story this is + * the shop's connection indicator: what the app is told when the network flaps. Proves the category names and the order the + * page promises, using three tools: a scripted local HTTP origin (exact status codes on demand), the local CONNECT proxy with an + * outage switch (a real subscription loses and regains its network) and the legacy manager. Deliberately not proven: the + * browser statuses listed above, they need a real browser network stack. + */ +export default page('status-events', 'P7', (world) => { + // Local helpers. `nextStatus` is like `expectStatus` in harness/wait.ts but resolves with the whole event, because several + // checks assert on `error` and other fields. Register it before the action, statuses are not replayed. + /** Resolves with the whole status event for the first status in `categories`. Register it before the action. */ + const nextStatus = (client: PubNub, categories: string[], timeout = 20_000): Promise => + new Promise((resolve, reject) => { + const listener: PubNub.Listener = { + status: (status) => { + if (!categories.includes(String(status.category))) return; + clearTimeout(timer); + setImmediate(() => client.removeListener(listener)); + resolve(status); + }, + }; + const timer = setTimeout(() => { + client.removeListener(listener); + reject(new Error(`no status in [${categories.join(', ')}] within ${timeout}ms`)); + }, timeout); + client.addListener(listener); + }); + + const lines = (logs: string[], prefix: string): string[] => logs.filter((line) => line.startsWith(prefix)); + + // ---- before and after: PNConnectedCategory on every change versus PNSubscriptionChangedCategory --------------- + + // The two migration samples run at P4, ahead of the outage checks. Both use real subscriptions on the open keyset. + // Before: the legacy manager (`enableEventEngine: false`) fires PNConnectedCategory on every change of the channel mix. + step('statusEventsMigrationBefore', { phase: 'P4', proves: 'The legacy subscription manager reports PNConnectedCategory on every change of the channel mix.' }, async () => { + const pubnub = world.fresh('status-before', { enableEventEngine: false }); + const [c1, c2] = [world.n('ch1'), world.n('ch2')]; + await world.withConsole(async (logs) => { + // snippet.statusEventsMigrationBefore + pubnub.addListener({ + status: (statusEvent) => { + if (statusEvent.category === 'PNConnectedCategory') { + // This ran on first connect AND every time channels/groups were added or removed + console.log('Subscription updated:', statusEvent.affectedChannels, statusEvent.affectedChannelGroups); + } + } + }); + // snippet.end + + pubnub.subscribe({ channels: [c1] }); + await eventually(() => assert.equal(lines(logs.lines, 'Subscription updated:').length, 1), { label: 'first connect logged' }); + pubnub.subscribe({ channels: [c2] }); + await eventually(() => assert.equal(lines(logs.lines, 'Subscription updated:').length, 2), { label: 'channel change logged as PNConnectedCategory again' }); + assert.match(lines(logs.lines, 'Subscription updated:')[1], new RegExp(c2)); + pubnub.unsubscribeAll(); + }); + }); + + // After: the same two subscribes report PNConnectedCategory once and PNSubscriptionChangedCategory for the change. The final + // `sleep` is a negative wait, it gives a wrongly repeated PNConnectedCategory time to arrive before asserting it did not. + step('statusEventsMigrationAfter', { phase: 'P4', proves: 'The new subscribe loop reports PNConnectedCategory once and PNSubscriptionChangedCategory for later changes.' }, async () => { + const pubnub = world.fresh('status-after'); + const [c1, c2] = [world.n('ch1'), world.n('ch2')]; + await world.withConsole(async (logs) => { + // snippet.statusEventsMigrationAfter + pubnub.addListener({ + status: (statusEvent) => { + if (statusEvent.category === 'PNConnectedCategory') { + console.log('Subscribed and ready'); + } + if (statusEvent.category === 'PNSubscriptionChangedCategory') { + console.log('Subscription updated:', statusEvent.affectedChannels, statusEvent.affectedChannelGroups); + } + } + }); + // snippet.end + + const subscription1 = pubnub.channel(c1).subscription(); + subscription1.subscribe(); + await eventually(() => assert.equal(lines(logs.lines, 'Subscribed and ready').length, 1), { label: 'connected logged' }); + const subscription2 = pubnub.channel(c2).subscription(); + subscription2.subscribe(); + await eventually(() => assert.equal(lines(logs.lines, 'Subscription updated:').length, 1), { label: 'change logged as PNSubscriptionChangedCategory' }); + // The change reports every channel of the new mix, and PNConnectedCategory does not fire again. + assert.match(lines(logs.lines, 'Subscription updated:')[0], new RegExp(c1)); + assert.match(lines(logs.lines, 'Subscription updated:')[0], new RegExp(c2)); + await sleep(1000); + assert.equal(lines(logs.lines, 'Subscribed and ready').length, 1, 'PNConnectedCategory fires only when the subscription starts'); + pubnub.unsubscribeAll(); + }); + }); + + // ---- request statuses against a scripted origin ---------------------------------------------------------------- + + // No PubNub traffic for the categories: a throwaway local HTTP server answers with whatever `respond` is set to, so each documented category is + // triggered with a real HTTP status. `NoneRetryPolicy` keeps `time()` from retrying a 500. Records two docs bugs (a body that is not + // JSON is PNUnknownCategory, not PNMalformedResponseCategory, and a missing publishKey is a validation error, see the comments inline). + check('statusEventsRequestCategories', { phase: 'P0', timeout: 60_000, proves: 'Each documented status category is produced by the matching HTTP response from a local server.' }, async () => { + let respond: (res: http.ServerResponse) => void = (res) => res.end('[]'); + const server = http.createServer((_req, res) => respond(res)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + const client = (extra: Partial = {}): PubNub => + world.fresh('status-origin', { origin, ssl: false, keepAlive: false, retryConfiguration: PubNub.NoneRetryPolicy(), ...extra }); + const categoryOf = async (pubnub: PubNub): Promise<{ category: string; statusCode?: number }> => { + try { + await pubnub.time(); + } catch (error) { + const status = (error as { status?: { category?: string; statusCode?: number } }).status; + return { category: String(status?.category), statusCode: status?.statusCode }; + } + return { category: 'none (resolved)' }; + }; + const reply = (code: number, body: string) => (res: http.ServerResponse) => { + res.writeHead(code, { 'content-type': 'application/json' }).end(body); + }; + + respond = reply(400, '{"error":true,"message":"bad"}'); + assert.equal((await categoryOf(client())).category, 'PNBadRequestCategory', '400'); + respond = reply(403, '{"error":true,"message":"Forbidden"}'); + assert.equal((await categoryOf(client())).category, 'PNAccessDeniedCategory', '403'); + respond = reply(500, '{"error":true}'); + assert.equal((await categoryOf(client())).category, 'PNServerErrorCategory', '500'); + respond = reply(503, '{"error":true}'); + assert.equal((await categoryOf(client())).category, 'PNServerErrorCategory', '503'); + respond = reply(418, '{"error":true}'); + assert.equal((await categoryOf(client())).category, 'PNUnknownCategory', 'unhandled status code'); + // The page lists PNMalformedResponseCategory for an unexpected response from a proxy. The SDK reports a 200 with a body + // that is not JSON, an empty body and an HTML page all as PNUnknownCategory with statusCode 0 (live finding). + respond = reply(200, 'this is not json'); + assert.equal((await categoryOf(client())).category, 'PNUnknownCategory', '200 with a body that is not JSON'); + respond = (res) => res.writeHead(200, { 'content-type': 'text/html' }).end('proxy login'); + assert.equal((await categoryOf(client())).category, 'PNUnknownCategory', '200 with an HTML page'); + // A server that never answers: PNTimeoutCategory after transactionalRequestTimeout seconds. + respond = () => undefined; + const timedOut = client({ transactionalRequestTimeout: 1 }); + await assert.rejects(timedOut.publish({ channel: 'x', message: 'm' }), (error: unknown) => { + assert.equal(String((error as { status?: { category?: string } }).status?.category), 'PNTimeoutCategory', 'no answer within the timeout'); + return true; + }); + timedOut.destroy(); + // Nothing listens on the origin any more: PNNetworkIssuesCategory. + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + // publish() is excluded from the default retry policy, so it rejects at once. time() would keep retrying. + await assert.rejects(client().publish({ channel: 'x', message: 'm' }), (error: unknown) => { + assert.equal(String((error as { status?: { category?: string } }).status?.category), 'PNNetworkIssuesCategory', 'connection refused'); + return true; + }); + } finally { + if (server.listening) { + server.closeAllConnections(); + server.close(); + } + } + + // PNValidationErrorCategory: incomplete parameters. The docs also list PNBadRequestCategory for a missing publishKey, + // but the SDK reports a missing publishKey, channel or message as PNValidationErrorCategory (live finding). + const open = world.env.open!; + const withoutPublishKey = world.track(new PubNub({ subscribeKey: open.subscribeKey, userId: world.n('status-nokey') })); + for (const call of [ + () => withoutPublishKey.publish({ channel: 'x', message: 'm' }), + () => world.shopper.publish({ message: 'm' } as unknown as PubNub.Publish.PublishParameters), + () => world.shopper.publish({ channel: 'x' } as unknown as PubNub.Publish.PublishParameters), + ]) { + await assert.rejects(call(), (error: unknown) => { + assert.equal(String((error as { status?: { category?: string } }).status?.category), 'PNValidationErrorCategory'); + return true; + }); + } + }); + + // "The default policy applies only when enableEventEngine is true, and it retries only subscribe requests." (Configuration page) + // The default policy excludes message send, presence, files, message storage, channel groups, push, App Context and + // message actions, so time() is retried too. Against a server that answers 500, time() keeps retrying and stays pending. + check('statusEventsDefaultRetryAlsoRetriesTime', { timeout: 30_000, proves: 'The default retry policy also retries time(), so it stays pending against a server that answers 500.' }, async () => { + let hits = 0; + const server = http.createServer((_req, res) => { + hits += 1; + res.writeHead(500, { 'content-type': 'application/json' }).end('{"error":true}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + const pubnub = world.fresh('status-default-retry', { origin, ssl: false, keepAlive: false }); + const settled = pubnub.time().then(() => 'settled', () => 'settled'); + assert.equal(await Promise.race([settled, sleep(4500).then(() => 'pending')]), 'pending', 'time() is still retrying after 4.5 seconds'); + assert.ok(hits >= 2, `time() was retried (${hits} requests reached the server)`); + // publish() is excluded from the default policy: one request, one failure. + const before = hits; + await assert.rejects(pubnub.publish({ channel: 'x', message: 'm' })); + assert.equal(hits - before, 1, 'publish() is not retried'); + pubnub.destroy(); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + // ---- subscription statuses under an outage --------------------------------------------------------------------- + + // Outage tooling for the checks below. Traffic goes through `world.proxy` (harness/proxy.ts). `outage(true)` refuses new tunnels and + // destroys open ones, which is a network loss as the SDK sees it. The outage is always switched off in `finally`. + /** A proxied client on the open keyset subscribed to one channel, resolved once connected. */ + const proxiedSubscriber = async (label: string, extra: Partial) => { + await world.proxy.start(); + world.proxy.outage(false); + const pubnub = world.fresh(label, extra); + pubnub.setProxy(world.proxy.config); + const channel = world.n('outage_channel'); + const ready = connected(pubnub, { channels: [channel] }); + const subscription = pubnub.channel(channel).subscription(); + subscription.subscribe(); + await ready; + return { pubnub, channel, subscription }; + }; + + // NoneRetryPolicy: the first lost request is reported at once as PNDisconnectedUnexpectedlyCategory. After the outage `reconnect()` + // brings the subscription back, and a message published by `friend` proves the loop works again. + check('statusEventsUnexpectedDisconnectAndReconnect', { timeout: 90_000, proves: 'With no retry policy a lost request reports PNDisconnectedUnexpectedlyCategory and reconnect() restores delivery.' }, async () => { + const { pubnub, channel, subscription } = await proxiedSubscriber('outage-none', { retryConfiguration: PubNub.NoneRetryPolicy() }); + try { + const before = world.proxy.connects; + const lost = nextStatus(pubnub, ['PNDisconnectedUnexpectedlyCategory', 'PNConnectionErrorCategory']); + world.proxy.outage(true); + const status = await lost; + // With the new subscription loop and no retries the loss is reported as PNDisconnectedUnexpectedlyCategory. + assert.equal(status.category, 'PNDisconnectedUnexpectedlyCategory'); + assert.ok((status as { error?: unknown }).error, 'the status carries an error field'); + console.log(`unexpected disconnect status: ${JSON.stringify(status)}`); + assert.ok(world.proxy.connects - before <= 1, 'NoneRetryPolicy makes no retry attempts'); + + // The outage ends and the app reconnects. + world.proxy.outage(false); + const back = nextStatus(pubnub, ['PNConnectedCategory', 'PNReconnectedCategory']); + pubnub.reconnect(); + const reconnected = await back; + console.log(`reconnect status category: ${reconnected.category}`); + const received = expectEvent(pubnub, 'message', (m) => (m.message as { outage?: string }).outage === 'after' && m.channel === channel); + await world.friend.publish({ channel, message: { outage: 'after' } }); + await received; + } finally { + world.proxy.outage(false); + subscription.unsubscribe(); + } + }); + + // The outage starts before the first subscribe, so the very first connect fails: PNConnectionErrorCategory, not a disconnect. + check('statusEventsConnectionErrorOnInitialConnect', { timeout: 60_000, proves: 'A failure on the first subscribe is PNConnectionErrorCategory and not a disconnect.' }, async () => { + await world.proxy.start(); + world.proxy.outage(true); + const pubnub = world.fresh('outage-initial', { retryConfiguration: PubNub.NoneRetryPolicy() }); + pubnub.setProxy(world.proxy.config); + try { + const failed = nextStatus(pubnub, ['PNConnectionErrorCategory', 'PNDisconnectedUnexpectedlyCategory']); + pubnub.channel(world.n('outage_channel')).subscription().subscribe(); + const status = await failed; + // Failed to establish the initial connection. + assert.equal(status.category, 'PNConnectionErrorCategory'); + assert.ok((status as { error?: unknown }).error, 'the status carries an error field'); + console.log(`connection error status: ${JSON.stringify(status)}`); + } finally { + world.proxy.outage(false); + pubnub.unsubscribeAll(); + } + }); + + // Measures, not mocks: counts CONNECT attempts the proxy sees while a policy retries during one outage. Bounds are loose on + // purpose (attempts at least 2, linear at least 3.5 s) because timing on a live network varies. The long timeout covers three + // subscribe and outage cycles. + check('statusEventsRetryPolicies', { timeout: 120_000, proves: 'During an outage each retry policy makes repeated connect attempts, counted at the proxy, and the linear policy spaces them out.' }, async () => { + // Count the tunnel attempts that a policy makes during one outage until the client gives up. + const attemptsDuring = async (label: string, retryConfiguration: PubNub.RequestRetryPolicy): Promise<{ attempts: number; ms: number }> => { + const { pubnub, subscription } = await proxiedSubscriber(label, { retryConfiguration }); + try { + const lost = nextStatus(pubnub, ['PNDisconnectedUnexpectedlyCategory', 'PNConnectionErrorCategory'], 60_000); + const before = world.proxy.connects; + const started = Date.now(); + world.proxy.outage(true); + await lost; + return { attempts: world.proxy.connects - before, ms: Date.now() - started }; + } finally { + world.proxy.outage(false); + subscription.unsubscribe(); + } + }; + + const none = await attemptsDuring('retry-none', PubNub.NoneRetryPolicy()); + const linear = await attemptsDuring('retry-linear', PubNub.LinearRetryPolicy({ delay: 2, maximumRetry: 2 })); + const exponential = await attemptsDuring('retry-exponential', PubNub.ExponentialRetryPolicy({ minimumDelay: 2, maximumDelay: 4, maximumRetry: 2 })); + console.log(`retry attempts during an outage: none ${none.attempts} (${none.ms} ms), linear ${linear.attempts} (${linear.ms} ms), exponential ${exponential.attempts} (${exponential.ms} ms)`); + assert.ok(none.attempts <= 1, 'NoneRetryPolicy does not retry'); + assert.ok(linear.attempts >= 2, `LinearRetryPolicy({ maximumRetry: 2 }) retries (saw ${linear.attempts} attempts)`); + assert.ok(linear.ms >= 3500, `Linear waits delay seconds between attempts (took ${linear.ms} ms)`); + assert.ok(exponential.attempts >= 2, `ExponentialRetryPolicy({ maximumRetry: 2 }) retries (saw ${exponential.attempts} attempts)`); + assert.ok(exponential.ms > none.ms, 'retries take time'); + }); + + // Default policy: a three second outage is retried inside the SDK. The client never reports a status for the in-between state and + // delivers a message published after the outage. + check('statusEventsDefaultRetryRecoversSilently', { timeout: 90_000, proves: 'A short outage is retried inside the SDK with no status and later messages still arrive.' }, async () => { + // Default policy (no retryConfiguration, enableEventEngine true): retries subscribe requests. A short outage ends without an unexpected-disconnect status. + const { pubnub, channel, subscription } = await proxiedSubscriber('outage-default', {}); + const categories: string[] = []; + pubnub.addListener({ status: (s) => categories.push(String(s.category)) }); + try { + const before = world.proxy.connects; + world.proxy.outage(true); + await sleep(3000); + assert.ok(world.proxy.connects > before, 'the default policy retried the subscribe request during the outage'); + world.proxy.outage(false); + const received = expectEvent(pubnub, 'message', (m) => (m.message as { outage?: string }).outage === 'default' && m.channel === channel, { timeout: 30_000 }); + await world.friend.publish({ channel, message: { outage: 'default' } }); + await received; + assert.deepEqual(categories, [], `no status for intermediate states (saw ${categories.join(', ')})`); + } finally { + world.proxy.outage(false); + subscription.unsubscribe(); + } + }); + + // Legacy manager (`enableEventEngine: false`) reports an outage as PNNetworkIssuesCategory and the recovery as PNReconnectedCategory. + check('statusEventsLegacyManagerOutage', { timeout: 90_000, proves: 'The legacy manager reports an outage as PNNetworkIssuesCategory and recovery as PNReconnectedCategory.' }, async () => { + // enableEventEngine: false. The page lists PNNetworkIssuesCategory for an unreachable network and PNReconnectedCategory afterwards. + await world.proxy.start(); + world.proxy.outage(false); + const pubnub = world.fresh('outage-legacy', { enableEventEngine: false }); + pubnub.setProxy(world.proxy.config); + const channel = world.n('outage_channel'); + const seen: string[] = []; + pubnub.addListener({ status: (s) => seen.push(String(s.category)) }); + const ready = connected(pubnub, { channels: [channel] }); + pubnub.subscribe({ channels: [channel] }); + await ready; + try { + const issues = nextStatus(pubnub, ['PNNetworkIssuesCategory'], 30_000); + world.proxy.outage(true); + await issues; + const recovered = nextStatus(pubnub, ['PNReconnectedCategory'], 60_000); + world.proxy.outage(false); + await recovered; + console.log(`legacy manager statuses: ${seen.join(', ')}`); + } finally { + world.proxy.outage(false); + pubnub.unsubscribeAll(); + } + }); + + // Access Manager: a 403 on the subscribe loop is a PNConnectionErrorCategory with PNAccessDeniedCategory in the nested error. + check('statusEventsAccessDeniedOnSubscribe', { keyset: 'pam', timeout: 45_000, proves: 'A 403 on the subscribe loop is PNConnectionErrorCategory with PNAccessDeniedCategory nested.' }, async () => { + const pubnub = world.client('secured', 'status-denied'); + const denied = nextStatus(pubnub, ['PNConnectionErrorCategory', 'PNAccessDeniedCategory']); + pubnub.channel(world.n('am_room')).subscription().subscribe(); + const status = await denied; + assert.equal(status.category, 'PNConnectionErrorCategory'); + console.log(`access denied status: ${JSON.stringify(status)}`); + // The nested error is the category name as a string. + assert.equal((status as { error?: unknown }).error, 'PNAccessDeniedCategory'); + pubnub.unsubscribeAll(); + }); +}); diff --git a/docs-snippets/getting-started/troubleshooting.ts b/docs-snippets/getting-started/troubleshooting.ts new file mode 100644 index 000000000..717978419 --- /dev/null +++ b/docs-snippets/getting-started/troubleshooting.ts @@ -0,0 +1,88 @@ +import assert from '../harness/assert.js'; +import PubNub from 'pubnub'; +import { page, check } from '../harness/step.js'; +import { rejectsValidation, rejectsWithCategory } from '../harness/assertions.js'; + +/** + * docs/sdks/javascript/troubleshooting.md + * + * The page has no code sample. Each entry states a symptom, a cause and a fix, so the checks reproduce the symptoms the + * app can reproduce and assert the category, status code and message that the page promises. Entries that need an + * outage or a scripted origin are proven in `getting-started/status-events.ts` and listed here as references: + * `PNDisconnectedUnexpectedlyCategory` (statusEventsUnexpectedDisconnectAndReconnect), `PNConnectionErrorCategory` + * (statusEventsConnectionErrorOnInitialConnect, statusEventsAccessDeniedOnSubscribe), `PNUnknownCategory` and + * `PNMalformedResponseCategory` (statusEventsRequestCategories), and the `PNConnectedCategory` upgrade note + * (statusEventsMigrationBefore, statusEventsMigrationAfter). + * + * Phase P0, open keyset (only `troubleshootingAccessDenied` uses the secured one and skips as `missing-env:secured` without it). + * In the Sneaker Drop story this is the support desk: a shopper's client is misconfigured and the page tells them why. + * Proves the category, status code and message the page promises for each symptom the app can reproduce. Deliberately does + * not prove browser-only symptoms (CORS, shared worker) or anything that needs a real device. Checks only, no snippets, so + * nothing here is embedded in the docs. + */ +export default page('getting-started/troubleshooting', 'P0', (world) => { + // Each row is one documented cause of "the constructor throws". The SDK throws a PubNubError synchronously, before any request. + check('troubleshootingConstructorThrows', { proves: 'Each documented cause of a constructor failure throws a PubNubError before any request.' }, () => { + const keys = { subscribeKey: world.env.open!.subscribeKey }; + // "userId is omitted, is empty or only whitespace, or is set together with uuid." + for (const [config, pattern] of [ + [{ ...keys }, /'userId' not set/], + [{ ...keys, userId: '' }, /'userId' not set/], + [{ ...keys, userId: ' ' }, /'userId' is empty/], + [{ ...keys, userId: 'a', uuid: 'b' }, /use only 'userId'/], + ] as const) { + assert.throws( + () => new PubNub(config as unknown as PubNub.PubNubConfiguration), + (error: unknown) => (error as Error).name === 'PubNubError' && pattern.test((error as Error).message), + ); + } + }); + + // Records a docs bug: the page says PNBadRequestCategory for a missing publishKey, the SDK reports PNValidationErrorCategory. + // No network: the validation runs before the request is built. + check('troubleshootingPublishValidation', { proves: 'A publish without a publish key fails local validation, and the category differs from what the page states.' }, async () => { + const withoutPublishKey = world.track(new PubNub({ subscribeKey: world.env.open!.subscribeKey, userId: world.n('trouble-nokey') })); + // The page says PNBadRequestCategory for a missing publishKey. The SDK reports PNValidationErrorCategory (live finding). + await rejectsValidation(withoutPublishKey.publish({ channel: 'x', message: 'm' }), /Missing 'publishKey'/); + await rejectsValidation(world.shopper.publish({ message: 'm' } as unknown as PubNub.Publish.PublishParameters), /Missing 'channel'/); + await rejectsValidation(world.shopper.publish({ channel: 'x' } as unknown as PubNub.Publish.PublishParameters), /Missing 'message'/); + }); + + // "Publish fails with Message Too Large ... PubNub rejects the publish with HTTP 400." Record what the service does. + check('troubleshootingMessageTooLarge', { timeout: 60_000, proves: 'The service rejects an oversized publish and the step records the answer.' }, async () => { + const channel = world.n('my_channel'); + const outcome = async (size: number, sendByPost: boolean): Promise<{ statusCode?: number; category?: string; message?: string } | 'published'> => { + try { + await world.shopper.publish({ channel, message: 'a'.repeat(size), sendByPost, storeInHistory: false }); + return 'published'; + } catch (error) { + const status = (error as { status?: { statusCode?: number; category?: string; errorData?: { message?: string } } }).status; + return { statusCode: status?.statusCode, category: status?.category, message: status?.errorData?.message }; + } + }; + assert.equal(await outcome(30_000, true), 'published', 'a message below 32 KiB is published'); + const post = await outcome(33_000, true); + const get = await outcome(33_000, false); + console.log(`message above the limit: POST ${JSON.stringify(post)}, GET ${JSON.stringify(get)}`); + // The page promises HTTP 400 with the text "Message Too Large". The service answers 413 for POST and 414 for GET (live finding). + assert.notEqual(post, 'published'); + assert.notEqual(get, 'published'); + assert.equal((post as { statusCode?: number }).statusCode, 413); + assert.equal((get as { statusCode?: number }).statusCode, 414); + }); + + // "Access is denied (403)": a client without a token on the Access Manager keyset. + // Uses the secured keyset because the open keyset has Access Manager (v2/v3) off, so a missing token is only denied here. The client is + // created without `setToken`, so every call is denied. The service name in errorData proves the 403 comes from Access Manager. + check('troubleshootingAccessDenied', { keyset: 'pam', timeout: 30_000, proves: 'A client without a token is denied on the secured keyset, and the error names Access Manager as the source.' }, async () => { + const pubnub = world.client('secured', 'trouble-denied'); + await assert.rejects(pubnub.publish({ channel: world.n('am_room'), message: 'm' }), (error: unknown) => { + const status = (error as { status?: { category?: string; statusCode?: number; errorData?: { service?: string } } }).status; + assert.equal(status?.category, 'PNAccessDeniedCategory'); + assert.equal(status?.statusCode, 403); + assert.equal(status?.errorData?.service, 'Access Manager'); + return true; + }); + await rejectsWithCategory(pubnub.fetchMessages({ channels: [world.n('am_room')] }), 'PNAccessDeniedCategory'); + }); +}); diff --git a/docs-snippets/harness/assert.ts b/docs-snippets/harness/assert.ts new file mode 100644 index 000000000..2ce9ea53b --- /dev/null +++ b/docs-snippets/harness/assert.ts @@ -0,0 +1,116 @@ +/** + * Recorded drop-in for `node:assert/strict`. + * + * Page modules import this instead of Node's module (only the import line changed, the calls are the same). Every call + * behaves exactly like the Node original: same checks, same errors, same return values, and the same TypeScript types, + * so `assert.ok(x)` still narrows. On top of that each call is written to the recorder (recorder.ts), which is how the run + * report lists the assertions of every step with their messages. + * + * It wraps every function Node exports (`ok`, `equal`, `deepEqual`, `match`, `rejects`, `throws`, `fail`, ...), so a method + * a step starts using later is recorded without touching this file. + */ +import nodeAssert from 'node:assert/strict'; +import { appFrame, recorder, short } from './recorder.js'; + +type AnyFn = (...args: unknown[]) => unknown; + +/** Where the optional message sits for each assertion. The `throws` family takes it last, the others at a fixed position. */ +function messageOf(kind: string, args: unknown[]): string | undefined { + const lastIsMessage = (): string | undefined => { + const last = args[args.length - 1]; + return args.length > 1 && typeof last === 'string' ? last : undefined; + }; + switch (kind) { + case 'ok': + return typeof args[1] === 'string' ? args[1] : undefined; + case 'fail': + return typeof args[0] === 'string' ? args[0] : undefined; + case 'throws': + case 'rejects': + case 'doesNotThrow': + case 'doesNotReject': + return lastIsMessage(); + case 'ifError': + return undefined; + default: + // equal, notEqual, deepEqual, strictEqual, match, ... take (actual, expected, message). + return typeof args[2] === 'string' ? args[2] : undefined; + } +} + +/** The label of an assertion: its message, or a generated line that shows what was compared. */ +function labelOf(kind: string, args: unknown[]): string { + const message = messageOf(kind, args); + if (message) return message; + switch (kind) { + case 'ok': + return `ok: ${short(args[0])}`; + case 'match': + case 'doesNotMatch': + return `${kind}: ${short(args[0])} against ${short(args[1])}`; + case 'throws': + case 'rejects': + case 'doesNotThrow': + case 'doesNotReject': + return `${kind}: ${typeof args[1] === 'function' || args[1] instanceof RegExp || typeof args[1] === 'object' ? 'with the expected error' : 'any error'}`; + case 'fail': + return 'fail'; + default: + return `${kind}: ${short(args[0], 50)} and ${short(args[1], 50)}`; + } +} + +/** Records a failed assertion with the values and the first frame inside the suite. */ +function recordFailure(kind: string, args: unknown[], error: unknown, callSite: Error | undefined): void { + const e = error as { actual?: unknown; expected?: unknown; stack?: string }; + recorder.record({ + kind, + label: labelOf(kind, args), + ok: false, + actual: 'actual' in (e ?? {}) ? short(e.actual, 200) : undefined, + expected: 'expected' in (e ?? {}) ? short(e.expected, 200) : undefined, + frame: appFrame(e?.stack) ?? appFrame(callSite?.stack), + }); +} + +function wrap(kind: string, fn: AnyFn): AnyFn { + return function recordedAssertion(...args: unknown[]): unknown { + // A rejected async assertion loses the caller in its stack, so remember the call site up front (async kinds only). + const callSite = kind === 'rejects' || kind === 'doesNotReject' ? new Error() : undefined; + let result: unknown; + try { + result = fn.apply(nodeAssert, args); + } catch (error) { + recordFailure(kind, args, error, callSite); + throw error; + } + if (result instanceof Promise) { + return result.then( + (value) => { + recorder.record({ kind, label: labelOf(kind, args), ok: true }); + return value; + }, + (error) => { + recordFailure(kind, args, error, callSite); + throw error; + }, + ); + } + recorder.record({ kind, label: labelOf(kind, args), ok: true }); + return result; + }; +} + +// `assert(value, message)` is `assert.ok`. Every other export that is a function gets the same wrapper, except classes +// (AssertionError) and the `strict` self-reference. +const recorded = wrap('ok', nodeAssert as unknown as AnyFn) as AnyFn & Record; +for (const [name, member] of Object.entries(nodeAssert)) { + if (typeof member !== 'function' || name === 'strict' || /^[A-Z]/.test(name)) continue; + recorded[name] = wrap(name, member as AnyFn); +} +recorded.AssertionError = nodeAssert.AssertionError; +recorded.strict = recorded; + +// Typed as the Node module so every assertion signature (including `asserts value`) is kept. +export const assert: typeof nodeAssert = recorded as unknown as typeof nodeAssert; +export default assert; diff --git a/docs-snippets/harness/assertions.ts b/docs-snippets/harness/assertions.ts new file mode 100644 index 000000000..86fb643de --- /dev/null +++ b/docs-snippets/harness/assertions.ts @@ -0,0 +1,33 @@ +/** + * Small assertion helpers that steps share. They exist because the SDK reports failures as a rejected promise + * carrying a `status` object (category, statusCode, errorData) rather than as a typed error class, so "this call must + * be refused for reason X" is the same few lines everywhere. + */ +import assert from './assert.js'; + +/** + * Adds `delta` to a PubNub timetoken (17-digit string) without losing precision. Timetokens are 10 ns ticks and exceed + * Number.MAX_SAFE_INTEGER, so plain arithmetic would round. BigInt keeps it exact. + */ +export function addToTimetoken(timetoken: string, delta: number): string { + return (BigInt(timetoken) + BigInt(delta)).toString(); +} + +/** Resolves when `promise` rejects with the SDK's validation error and the status message matches. */ +export async function rejectsValidation(promise: Promise, pattern: RegExp): Promise { + await assert.rejects(promise, (error: unknown) => { + const status = (error as { status?: { category?: string; message?: unknown } }).status; + assert.equal(String(status?.category), 'PNValidationErrorCategory', 'expected a validation error'); + assert.match(String(status?.message ?? ''), pattern); + return true; + }); +} + +/** Resolves when `promise` rejects with an SDK error whose category matches (for example access denied). */ +export async function rejectsWithCategory(promise: Promise, category: string): Promise { + await assert.rejects(promise, (error: unknown) => { + const status = (error as { status?: { category?: string } }).status; + assert.equal(String(status?.category), category); + return true; + }); +} diff --git a/docs-snippets/harness/cli.ts b/docs-snippets/harness/cli.ts new file mode 100644 index 000000000..55bd10932 --- /dev/null +++ b/docs-snippets/harness/cli.ts @@ -0,0 +1,857 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import Mocha from 'mocha'; +import { addSecrets, installRedaction, knownSecrets, redact } from './redact.cjs'; +import { + ciRunId, + describeRoles, + ENV_VARS, + loadEnv, + randomRunId, + ROLES, + runIdAgeMs, + type Env, + type Role, +} from './env.js'; +import { lintApp, printLint } from './lint-markers.js'; +import { stage } from './stage.js'; +import { runJanitor } from './janitor.js'; +import { featureSkips, launchProblems, outcomeProblems } from './policy.js'; +import { classifyPreflightError, preflightMessage } from './preflight-errors.js'; +import { buildManifest, docsIds, serialize } from './manifest.js'; +import { provisionDataSync } from '../provision/provision-datasync.js'; +import type { Registry, Selection } from './registry.js'; + +/** + * Commands (root package.json scripts): + * live npm run test:docs-snippets [-- --grep ] [-- --shard ] + * dry npm run test:docs-snippets:dry no network + * janitor npm run test:docs-snippets:janitor leftovers older than six hours, and the application-level grants + * manifest npm run docs-snippets:manifest [-- --check] + * stage only build docs-snippets/.run// + * lint marker lint only + * + * Run flow of `live`: + * 1. install output redaction, load env (prints "set" or "missing" per role, never a value), pick the run id + * 2. launch policy (CI refuses subset runs and debug logging), marker lint, stage into `.run//` + * 3. compute the selection from the staged plan. When it includes the datasync role, set up the DataSync classes first + * (provision/provision-datasync.ts), before preflight and before any step + * 4. run Mocha programmatically on `.run//harness/mocha-entry.ts`. Its root hooks prove Access Manager enforcement + * before and after, and run the janitor after. The reporter writes report/run.json + * 5. read that report back and apply the outcome policy (skip budget, required features, strict, residue) + * 6. wait for the event loop to drain. If it is still busy 10 s later, print the open handles and exit 1 + * + * Mocha is driven from here and not through a config file alone because the file to run only exists after staging. + * `docs-snippets/.mocharc.cjs` holds the shared Mocha options (reporter, retries, timeout) and this file reads it. + */ + +const SUITE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +// The default local env file, passed on through the environment so the staged copy under .run// reads it too. +if (!process.env.DOCS_SNIPPETS_ENV_FILE && fs.existsSync(path.join(SUITE_DIR, '.env.local'))) { + process.env.DOCS_SNIPPETS_ENV_FILE = path.join(SUITE_DIR, '.env.local'); +} +const REPORT_DIR = path.join(SUITE_DIR, 'report'); +const args = process.argv.slice(2); +const command = args[0] && !args[0].startsWith('-') ? args[0] : 'live'; +const rest = command === args[0] ? args.slice(1) : args; + +/** `--name value` or `--name=value`. */ +function option(name: string): string | undefined { + for (let i = 0; i < rest.length; i += 1) { + if (rest[i] === name) return rest[i + 1]; + if (rest[i].startsWith(`${name}=`)) return rest[i].slice(name.length + 1); + } + return undefined; +} +const flags = new Set(rest.filter((a) => a.startsWith('--'))); + +async function mochaOptions(): Promise { + const config = (await import(pathToFileURL(path.join(SUITE_DIR, '.mocharc.cjs')).href)) as { + default: Mocha.MochaOptions; + }; + return { ...config.default, reporter: path.join(SUITE_DIR, 'harness', 'reporter.cjs') }; +} + +/** Imports the staged plan and registers it, without a network. Returns the staged registry. */ +async function stagedPlan( + runDir: string, + env: Env, + runId: string, +): Promise<{ + registry: Registry; + selection: (f: { shard?: Role; grep?: string; only?: string[] }) => Selection; + roles: (s: Selection) => Set; +}> { + const run = (await import(pathToFileURL(path.join(runDir, 'run.ts')).href)) as typeof import('../run.js'); + const worldModule = (await import( + pathToFileURL(path.join(runDir, 'harness', 'world.ts')).href + )) as typeof import('./world.js'); + const reg = (await import( + pathToFileURL(path.join(runDir, 'harness', 'registry.ts')).href + )) as typeof import('./registry.js'); + const world = new worldModule.World(env, runId); + run.registry.reset(); + run.registry.availability = { open: true, pam: true, datasync: true }; + run.registerAll(world); + return { + registry: run.registry, + selection: (f) => reg.selectSteps(run.registry, f), + roles: (s) => reg.rolesOf(run.registry, s), + }; +} + +/** Exits with `code` once the event loop is empty. If it is still busy after 10 s, prints the open handles and exits 1. */ +function exitWhenIdle(code: number): void { + const timer = setTimeout(() => { + const resources = process.getActiveResourcesInfo(); + // Real I/O: anything that could still talk to the service, a keyset or the machine. Timers alone cannot. + const io = resources.filter((r) => !['Timeout', 'Immediate', 'PipeWrap', 'TTYWrap'].includes(r)); + const lines = describeHandles(); + if (io.length > 0) { + console.error(`the event loop is still busy 10 s after the run finished (run exit code ${code}). Open handles:`); + console.error(` ${lines.join('\n ')}`); + process.exit(1); + } + // Only timers are left, for example a retry timer inside a destroyed SDK client. They cannot change any result, so the + // run keeps its exit code, but they are named so a real leak never goes unnoticed. + console.error( + `warning: only timers keep the event loop busy 10 s after the run (${resources.filter((r) => r === 'Timeout').length} timer(s)), exiting with the run's exit code ${code}`, + ); + process.exit(code); + }, 10_000); + timer.unref(); + process.once('beforeExit', () => { + clearTimeout(timer); + process.exit(code); + }); +} + +/** + * What keeps the event loop alive, one line per handle. Node's own lists, so nothing has to be loaded before the handles were + * created (why-is-node-running only sees handles created after it loads). Sockets show the remote host and port, timers + * their delay, child processes their pid. No key appears here, and stderr is redacted anyway. + */ +function describeHandles(): string[] { + const lines: string[] = []; + const handles = (process as unknown as { _getActiveHandles?: () => unknown[] })._getActiveHandles?.() ?? []; + for (const handle of handles) { + if (handle === process.stdout || handle === process.stderr || handle === process.stdin) continue; + const h = handle as { + constructor?: { name?: string }; + remoteAddress?: string; + remotePort?: number; + _host?: string; + servername?: string; + pid?: number; + spawnfile?: string; + _idleTimeout?: number; + address?: () => unknown; + hasRef?: () => boolean; + }; + const name = h.constructor?.name ?? typeof handle; + const detail: string[] = []; + if (h.servername || h._host) detail.push(`host ${h.servername ?? h._host}`); + if (h.remoteAddress) detail.push(`remote ${h.remoteAddress}:${h.remotePort ?? ''}`); + if (h.pid) detail.push(`pid ${h.pid}${h.spawnfile ? ` ${h.spawnfile}` : ''}`); + if (typeof h._idleTimeout === 'number' && h._idleTimeout > 0) detail.push(`timeout ${h._idleTimeout} ms`); + if (name === 'Server' && typeof h.address === 'function') detail.push(`listening ${JSON.stringify(h.address())}`); + if (typeof h.hasRef === 'function' && !h.hasRef()) detail.push('unref'); + lines.push(`${name}${detail.length ? ` (${detail.join(', ')})` : ''}`); + } + const resources = process.getActiveResourcesInfo().filter((r) => r !== 'TTYWrap'); + lines.push(`resource types: ${resources.join(', ') || 'none'}`); + return lines; +} + +async function live(): Promise { + installRedaction(); + const shardArg = option('--shard'); + if (shardArg) process.env[ENV_VARS.shard] = shardArg; + const env = loadEnv(); + console.log(describeRoles(env)); + const grep = option('--grep'); + if (!env.open && !env.pam && !env.datasync) { + console.log( + `skipped: no keyset is configured. Set the env names in docs-snippets/README.md or ${ENV_VARS.envFile}.`, + ); + return env.ci || flags.has('--require-env') ? 1 : 0; + } + const launch = launchProblems(env, process.env, { grep }); + if (launch.length > 0) { + launch.forEach((p) => console.error(p)); + return 1; + } + const runId = env.runId ?? (env.ci ? ciRunId(process.env, env.shard) : randomRunId()); + process.env[ENV_VARS.runId] = runId; + process.env.DOCS_SNIPPETS_REPORT_DIR = REPORT_DIR; + if (grep) process.env.DOCS_SNIPPETS_GREP = grep; + else delete process.env.DOCS_SNIPPETS_GREP; + console.log(`run id ${runId}${env.shard ? `, shard ${env.shard}` : ''} (staging in docs-snippets/.run/${runId}/)`); + + const phase = timer(); + const lint = lintApp(SUITE_DIR); + printLint(lint); + if (lint.errors.length > 0) return 1; + const staged = stage(SUITE_DIR, runId, { mutate: process.env.DOCS_SNIPPETS_MUTATE || undefined }); + console.log( + `staged ${staged.filesCopied} files, ${staged.rewrites.length} literal rewrites, ${staged.ranMarkers} snippet.end markers instrumented${staged.ranFallbacks.length ? ` (AST fallback: ${staged.ranFallbacks.map((f) => f.id).join(', ')})` : ''}${staged.mutated ? `, mutated ${staged.mutated}` : ''}`, + ); + if (staged.problems.length > 0) { + staged.problems.forEach((p) => console.error(p)); + return 1; + } + console.log(`phase stage: ${phase()}`); + + const only = process.env.DOCS_SNIPPETS_ONLY?.split(',') + .map((x) => x.trim()) + .filter(Boolean); + const plan = await stagedPlan(staged.runDir, env, runId); + const selection = plan.selection({ shard: env.shard, grep, only }); + const roles = plan.roles(selection); + console.log( + `selected ${selection.run.size - selection.prerequisites.size} step(s), ${selection.prerequisites.size} prerequisite(s), roles ${[...roles].join(', ')}`, + ); + if (selection.run.size === 0) { + console.error('no step matches the selection'); + return 1; + } + + if (roles.has('datasync')) { + const setup = timer(); + console.log('DataSync class setup (before preflight and before any step):'); + const result = await provisionDataSync(env, { check: false }); + result.log.forEach((line) => console.log(` ${line}`)); + if (!result.ok) { + result.problems.forEach((p) => console.error(`DataSync class setup failed: ${p}`)); + return 1; + } + console.log(`phase datasync setup: ${setup()}`); + } + + const mocha = new Mocha(await mochaOptions()); + mocha.addFile(path.join(staged.runDir, 'harness', 'mocha-entry.ts')); + await mocha.loadFilesAsync(); + const steps = timer(); + const failures = await new Promise((resolve) => { + mocha.run((count) => resolve(count)); + }); + console.log(`phase steps: ${steps()}`); + + const reportPath = path.join(REPORT_DIR, 'run.json'); + let problems: string[] = []; + let skips: Array<{ id: string; reason: string; note?: string }> = []; + if (fs.existsSync(reportPath)) { + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as { + runId: string; + problems: string[]; + skips: Array<{ id: string; reason: string; note?: string }>; + }; + if (report.runId !== runId) problems.push(`report/run.json belongs to run ${report.runId}, not ${runId}`); + problems = [...problems, ...report.problems]; + skips = report.skips; + } else { + problems.push('no report was written to docs-snippets/report/run.json'); + } + problems.push(...outcomeProblems(env, { skips })); + for (const skip of skips) console.log(`skip: ${skip.id} (${skip.reason})${skip.note ? ` ${skip.note}` : ''}`); + for (const skip of featureSkips({ skips })) console.log(`feature skip: ${skip.id} (${skip.reason})`); + problems.forEach((p) => console.error(p)); + return failures > 0 || problems.length > 0 ? 1 : 0; +} + +function timer(): () => string { + const started = Date.now(); + return () => `${((Date.now() - started) / 1000).toFixed(1)} s`; +} + +/** No network: everything that can be proven without keys. */ +async function dryRun(): Promise { + let failed = false; + const fail = (message: string): void => { + failed = true; + console.error(`FAIL ${message}`); + }; + const heading = (title: string): void => console.log(`\n== ${title}`); + + heading('1. Environment gate (no keys needed)'); + const emptyEnv = loadEnv({}); + console.log(` with no env names set: ${describeRoles(emptyEnv)}`); + if (emptyEnv.open || emptyEnv.pam || emptyEnv.datasync) fail('empty environment produced a keyset'); + { + // An env file with the older PN_DOCS_* names maps onto the suite's names, and real variables win. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-snippets-env-')); + const file = path.join(dir, '.env.local'); + // The older names come from the mapping table itself, so this file holds no copy of them. + const { envSource, FILE_ALIASES } = await import('./env.js'); + const alias = (name: string): string => FILE_ALIASES[name][0]; + fs.writeFileSync( + file, + [ + `${alias('PUBLISH_KEY')}=pub-file`, + `${alias('SUBSCRIBE_KEY')}=sub-file`, + `${alias('PAM_PUBLISH_KEY')}=pub-sec`, + `${alias('PAM_SUBSCRIBE_KEY')}=sub-sec`, + `${alias('PAM_SECRET_KEY')}=sec-sec`, + ].join('\n'), + ); + const mapped = loadEnv(envSource({ [ENV_VARS.envFile]: file, PUBLISH_KEY: 'pub-real' })); + if (mapped.open?.publishKey !== 'pub-real' || mapped.open?.subscribeKey !== 'sub-file') + fail('env file: real variables must win over the file'); + if (mapped.pam?.secretKey !== 'sec-sec' || mapped.datasync?.secretKey !== 'sec-sec' || !mapped.sharedSecured) + fail('env file: the secured keyset must fill pam and datasync'); + const line = describeRoles(mapped); + if (/pub-file|sub-file|pub-sec|sub-sec|sec-sec|pub-real/.test(line)) fail('the loader output contains a key value'); + console.log(` env file mapping: ${line.replace(dir, '')}`); + fs.rmSync(dir, { recursive: true, force: true }); + } + + heading('2. Marker lint'); + const lint = lintApp(SUITE_DIR); + printLint(lint); + if (lint.errors.length > 0) failed = true; + + heading('3. Staging rewrite and honesty instrumentation'); + const runId = randomRunId(); + const staged = stage(SUITE_DIR, runId); + console.log(` copied ${staged.filesCopied} files, ${staged.rewrites.length} literal rewrites with run id ${runId}`); + for (const from of [ + 'my_channel', + 'chats.room1', + 'uuid', + 'replace_with_message_timetoken', + 'replace-with-end-timetoken', + ]) { + const hit = staged.rewrites.find((r) => r.from === from); + if (hit) console.log(` sample ${hit.file}:${hit.line} '${hit.from}' -> ${hit.to}`); + else fail(`no rewrite found for sample literal '${from}'`); + } + staged.problems.forEach((p) => fail(p)); + const leaked = staged.rewrites.filter( + (r) => r.kind === 'name' && fs.readFileSync(path.join(SUITE_DIR, r.file), 'utf8').includes(r.to), + ); + if (leaked.length > 0) fail(`source files contain rewritten names, e.g. ${leaked[0].file}: ${leaked[0].to}`); + const stepIds = lint.snippets.filter((s) => s.step).length; + console.log( + ` __snippetRan on ${staged.ranMarkers} of ${stepIds} snippet.end lines inside step bodies, AST fallback for: ${staged.ranFallbacks.map((f) => `${f.id} (${f.file}:${f.line})`).join(', ') || 'none'}`, + ); + if (staged.ranMarkers !== stepIds) + fail(`expected ${stepIds} instrumented snippet.end lines, got ${staged.ranMarkers}`); + if (!fs.realpathSync(path.join(staged.runDir, 'node_modules', 'pubnub')).startsWith(path.resolve(SUITE_DIR, '..'))) + fail('the staged node_modules/pubnub link does not point at this repository'); + { + let other = randomRunId(); + while (other === runId) other = randomRunId(); + const mutated = stage(SUITE_DIR, other, { mutate: 'publishBasicUsage' }); + const text = fs.readFileSync(path.join(mutated.runDir, 'api-reference', 'publish.ts'), 'utf8'); + if ( + mutated.mutated !== 'publishBasicUsage' || + !/throw new Error\('DOCS_SNIPPETS_MUTATE publishBasicUsage'\); __snippetStart\('publishBasicUsage'\); \/\/ snippet\.publishBasicUsage/.test( + text, + ) + ) + fail('DOCS_SNIPPETS_MUTATE did not insert the throw at the start marker'); + else + console.log( + ' DOCS_SNIPPETS_MUTATE=publishBasicUsage inserts the throw at the start marker of the staged copy only', + ); + if (!fs.existsSync(staged.runDir)) fail('a second stage removed the first one'); + fs.rmSync(mutated.runDir, { recursive: true, force: true }); + } + + heading('4. Step plan from the staged copy'); + const plan = await stagedPlan(staged.runDir, emptyEnv, runId); + const registry = plan.registry; + const wiring = registry.validate(); + wiring.forEach((p) => fail(p)); + const ordered = registry.ordered(); + const byRole = Object.fromEntries(ROLES.map((r) => [r, ordered.filter((s) => s.keyset === r).length])); + console.log( + ` ${ordered.length} steps registered (${ROLES.map((r) => `${r} ${byRole[r]}`).join(', ')}), ${wiring.length} wiring problems`, + ); + const cross = registry.crossRoleNeeds(); + console.log(` cross-role needs edges (${cross.length}):`); + for (const e of cross) console.log(` ${e.step} (${e.role}) needs ${e.needs} (${e.needsRole})`); + + heading('5. Static mapping: lint, registry and manifest agree'); + const lintIds = new Map( + lint.snippets.filter((s) => !s.importOnly && !s.file.startsWith('quickstarts')).map((s) => [s.id, s.step]), + ); + for (const s of ordered.filter((x) => x.kind === 'step')) { + for (const id of s.snippets) + if (lintIds.get(id) !== s.id) + fail(`snippet "${id}" is declared by step "${s.id}" but lint found it in step "${lintIds.get(id)}"`); + } + const claimed = new Set(ordered.flatMap((s) => s.snippets)); + for (const [id] of lintIds) if (!claimed.has(id)) fail(`snippet "${id}" is not run by any registered step`); + const built = buildManifest(lint, registry); + built.problems.forEach((p) => fail(`manifest: ${p}`)); + const manifestFile = path.join(SUITE_DIR, 'coverage.json'); + if (!fs.existsSync(manifestFile) || fs.readFileSync(manifestFile, 'utf8') !== serialize(built.manifest)) + fail('coverage.json is stale, run npm run docs-snippets:manifest'); + else console.log(` coverage.json is current: ${Object.keys(built.manifest.snippets).length} entries`); + console.log( + ` ${claimed.size} snippet ids run by steps, ${lint.snippets.filter((s) => s.importOnly).length} import-only snippets type checked only`, + ); + + heading('6. Selection and skip propagation (simulated, no network)'); + { + const one = plan.selection({ grep: 'publishBasicUsage' }); + const chosen = [...one.run].filter((id) => !one.prerequisites.has(id)); + console.log( + ` --grep publishBasicUsage selects ${chosen.join(', ')} and runs prerequisites ${[...one.prerequisites].join(', ') || 'none'}`, + ); + if (!chosen.includes('publishBasicUsage')) fail('--grep publishBasicUsage did not select the step'); + for (const need of registry.get('publishBasicUsage')?.needs ?? []) + if (!one.run.has(need)) fail(`prerequisite ${need} of publishBasicUsage would not run`); + for (const role of ROLES) { + const shard = plan.selection({ shard: role }); + const foreign = [...shard.prerequisites].filter((id) => registry.get(id)?.keyset !== role); + console.log( + ` shard ${role}: ${shard.run.size - shard.prerequisites.size} steps, ${foreign.length} prerequisite(s) of other roles`, + ); + } + const withDependents = ordered.find((s) => ordered.some((o) => o.needs.includes(s.id))); + if (!withDependents) fail('no step has dependents, nothing to simulate'); + else { + const result = registry.simulate([withDependents.id]); + const blocked = result.filter((r) => r.reason === `blocked-by:${withDependents.id}`); + console.log(` failing "${withDependents.id}" blocks ${blocked.length} step(s)`); + if (blocked.length === 0) fail(`failing "${withDependents.id}" blocked nothing`); + registry.availability = { open: true, pam: false, datasync: true }; + const noPam = registry.simulate([]).filter((r) => r.reason === 'missing-env:pam').length; + console.log(` without the pam keyset ${noPam} step(s) skip as missing-env:pam`); + registry.availability = { open: true, pam: true, datasync: true }; + } + } + + heading('7. Mocha binding and reporter (step bodies replaced by no-ops, no network)'); + { + const failId = ordered.find((s) => ordered.some((o) => o.needs.includes(s.id)))?.id; + const reportDir = path.join(staged.runDir, 'dry-report'); + process.env.DOCS_SNIPPETS_RUN_ID = runId; + process.env.DOCS_SNIPPETS_DRY = '1'; + if (failId) process.env.DOCS_SNIPPETS_DRY_FAIL = failId; + process.env.DOCS_SNIPPETS_REPORT_DIR = reportDir; + const savedSummary = process.env.GITHUB_STEP_SUMMARY; + delete process.env.GITHUB_STEP_SUMMARY; + const mocha = new Mocha({ ...(await mochaOptions()), timeout: 10_000 }); + mocha.addFile(path.join(staged.runDir, 'harness', 'mocha-entry.ts')); + await mocha.loadFilesAsync(); + const failures = await new Promise((resolve) => mocha.run((count) => resolve(count))); + for (const name of [ + 'DOCS_SNIPPETS_DRY', + 'DOCS_SNIPPETS_DRY_FAIL', + 'DOCS_SNIPPETS_RUN_ID', + 'DOCS_SNIPPETS_REPORT_DIR', + ]) + delete process.env[name]; + if (savedSummary) process.env.GITHUB_STEP_SUMMARY = savedSummary; + const report = JSON.parse(fs.readFileSync(path.join(reportDir, 'run.json'), 'utf8')) as { + runId: string; + summary: { passed: number; failed: number; skipped: number }; + steps: Array<{ + id: string; + status: string; + reason?: string; + snippets: string[]; + role: string; + assertions: number; + }>; + sdk: { version: string; commit: string }; + }; + console.log(` mocha reported ${failures} failure(s), run.json summary ${JSON.stringify(report.summary)}`); + if (failures !== 1) fail(`expected the one simulated failure, mocha reported ${failures}`); + if (!report.steps.some((s) => s.reason === `blocked-by:${failId}`)) + fail('run.json shows no step blocked by the simulated failure'); + if (report.runId !== runId) fail(`run.json carries run id ${report.runId}, expected ${runId}`); + // The commit is `unknown` in a copy without git history (a source download), the version always exists. + if (!report.sdk.version || !report.sdk.commit) fail('run.json lacks the SDK version or commit'); + const sample = report.steps.find((s) => s.status === 'passed' && s.snippets.length > 0); + if (!sample || !sample.role || !sample.snippets.includes(sample.id) || sample.assertions < 1) + fail('run.json step records lack role, snippet ids or assertion counts'); + const skippedBlocked = report.steps.filter((s) => s.reason?.startsWith('blocked-by:')).length; + console.log(` ${skippedBlocked} step(s) skipped as blocked-by the simulated failure of ${failId}, transitively`); + if (/0123abcd|AAAAAAAAAA/.test(fs.readFileSync(path.join(reportDir, 'run.json'), 'utf8'))) + fail('run.json leaks a key-shaped value'); + else console.log(' run.json has role, snippet ids, assertion counts, SDK version and commit, and is redacted'); + } + + heading('8. Run policy, preflight messages, redaction, run ids'); + { + const keys = { PUBLISH_KEY: 'pub-c-x', SUBSCRIBE_KEY: 'sub-c-x' }; + const base = loadEnv(keys); + const ci = loadEnv({ ...keys, CI: 'true' }); + const blocked = { + skips: [ + { id: 'a', reason: 'blocked-by:preflightPresence' }, + { id: 'b', reason: 'needs-feature:push-notifications' }, + ], + }; + if (outcomeProblems(base, blocked).length !== 0) fail('policy: blocked-by skips fail a local run by default'); + if (outcomeProblems(ci, blocked).length !== 1) + fail('policy: CI must fail on a blocked-by skip with the default budget'); + if (outcomeProblems(loadEnv({ ...keys, CI: 'true', DOCS_SNIPPETS_SKIP_BUDGET: '1' }), blocked).length !== 0) + fail('policy: DOCS_SNIPPETS_SKIP_BUDGET=1 should allow one blocked-by skip'); + if (launchProblems(ci, {}, { grep: 'x' }).length !== 1) fail('policy: --grep must fail in CI'); + for (const name of ['DOCS_SNIPPETS_ONLY', 'DOCS_SNIPPETS_MUTATE', 'DOCS_SNIPPETS_INJECT_FAILURE']) { + if (launchProblems(ci, { [name]: 'x' }).length !== 1) fail(`policy: ${name} must fail in CI`); + if (launchProblems(base, { [name]: 'x' }).length !== 0) fail(`policy: ${name} is fine locally`); + } + if (launchProblems(loadEnv({ CI: 'true', DOCS_SNIPPETS_LOG_LEVEL: 'debug' }), {}).length !== 1) + fail('policy: debug log level must be refused in CI'); + const featureOnly = { skips: [{ id: 'e', reason: 'needs-feature:datasync-events' }] }; + if (outcomeProblems(ci, featureOnly).length !== 0) + fail('policy: a needs-feature skip must not count against the CI skip budget'); + if ( + outcomeProblems(loadEnv({ ...keys, CI: 'true', DOCS_SNIPPETS_REQUIRE_FEATURES: 'datasync-events' }), featureOnly) + .length !== 1 + ) + fail('policy: DOCS_SNIPPETS_REQUIRE_FEATURES must fail the named feature skip'); + if (outcomeProblems(loadEnv({ ...keys, DOCS_SNIPPETS_STRICT: '1' }), featureOnly).length !== 1) + fail('policy: DOCS_SNIPPETS_STRICT=1 must make every skip fatal'); + if (outcomeProblems(ci, { skips: [{ id: 'm', reason: 'missing-env:pam' }] }).length !== 1) + fail('policy: missing-env must fail in CI'); + console.log( + ' run policy: skip budget, feature skips, strict, subset options and debug logging behave as documented', + ); + + const cases: Array<[string, unknown, string]> = [ + [ + 'network', + { status: { category: 'PNNetworkIssuesCategory' }, message: 'REST API request processing error' }, + 'Network problem', + ], + [ + 'bad key', + { status: { statusCode: 400, errorData: { message: 'Invalid Subscribe Key' } } }, + 'keys were rejected', + ], + ['feature off', { status: { statusCode: 403, errorData: { message: 'Forbidden' } } }, 'does not have Presence'], + [ + 'bad request', + { status: { statusCode: 400, errorData: { message: 'Invalid arguments' } } }, + 'not a missing feature', + ], + ]; + for (const [label, error, expect] of cases) { + const message = preflightMessage(error, 'open', 'Presence', 'Presence'); + if (!message.includes(expect)) fail(`preflight message for ${label} should contain "${expect}", got: ${message}`); + } + if (classifyPreflightError({ code: 'ENOTFOUND' }).cause !== 'network') + fail('preflight: ENOTFOUND is a network problem'); + console.log(' preflight errors: network, bad key, feature off and bad request read differently'); + + const fake = 'pub-c-0123abcd-0123-4abc-8def-0123456789ab'; + const masked = redact( + `config { publishKey: '${fake}', secretKey: 'sec-c-QWERTYUIOPASDFGHJKL1234', token: 'p0F2AkF0Gl${'A'.repeat(30)}' }`, + {}, + ); + if (masked.includes('0123abcd') || masked.includes('QWERTYUIOP') || masked.includes('AAAAAAAAAA')) + fail('redaction left a secret'); + else console.log(' redaction masks keys and tokens'); + + const id = randomRunId(); + if ((runIdAgeMs(id) ?? Infinity) > 10_000) fail(`a fresh run id (${id}) should decode to a moment just now`); + const shardIds = new Set(ROLES.map((r) => ciRunId({ GITHUB_RUN_ID: '123456', GITHUB_RUN_ATTEMPT: '1' }, r))); + const rerunIds = new Set(ROLES.map((r) => ciRunId({ GITHUB_RUN_ID: '123456', GITHUB_RUN_ATTEMPT: '2' }, r))); + if (shardIds.size !== 3) fail('CI run ids of the three shards of one attempt must differ'); + if ([...shardIds].some((x) => !/^[0-9a-z]{6}$/.test(x))) fail('CI run ids must be six base36 characters'); + console.log( + ` run ids carry their start time, CI shards get distinct ids (${[...shardIds].join(', ')}; attempt 2: ${[...rerunIds].join(', ')})`, + ); + } + + heading('9. Reporter self-test (no key value in any written file)'); + { + // Loaded key values (if a local env file or real variables are set) plus fake ones, put into every free text field. + const env = loadEnv(); + addSecrets(['fake-secret-value-0001', 'sub-c-ffffffff-ffff-4fff-8fff-ffffffffffff']); + const secrets = knownSecrets(); + const { buildReport, writeReport } = (await import( + pathToFileURL(path.join(SUITE_DIR, 'harness', 'reporter.cjs')).href + )) as { buildReport: (s: unknown[], c: unknown) => unknown; writeReport: (r: unknown, dir: string) => string[] }; + const text = `config ${secrets.join(' ')} pub-c-0123abcd-0123-4abc-8def-0123456789ab`; + const steps = [ + { + id: 'leaky', + snippets: ['leaky'], + page: 'x', + phase: 'P4', + kind: 'step', + role: 'open', + prerequisite: false, + status: 'failed', + error: text, + note: text, + durationMs: 1, + assertions: 1, + output: [text], + failedAssertion: { label: text, expected: text, actual: text }, + }, + { + id: 'skipped', + snippets: [], + page: 'x', + phase: 'P4', + kind: 'check', + role: 'pam', + prerequisite: false, + status: 'skipped', + reason: 'needs-feature:x', + note: text, + durationMs: 0, + assertions: 0, + }, + ]; + const report = buildReport(steps, { + plan: [], + selection: { shard: null, grep: text }, + runId, + startedAt: '', + finishedAt: '', + durationMs: 1, + sdk: { version: '0', commit: '0' }, + }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-snippets-report-')); + const savedSummary = process.env.GITHUB_STEP_SUMMARY; + const summary = path.join(dir, 'step-summary.md'); + process.env.GITHUB_STEP_SUMMARY = summary; + const written = writeReport(report, dir); + if (savedSummary) process.env.GITHUB_STEP_SUMMARY = savedSummary; + else delete process.env.GITHUB_STEP_SUMMARY; + let leaks = 0; + for (const file of written) { + const content = fs.readFileSync(file, 'utf8'); + if (secrets.some((s) => content.includes(s)) || content.includes('0123abcd')) { + leaks += 1; + console.error(`leak in ${path.basename(file)}`); + } + } + if (leaks > 0) fail('the reporter leaks key values'); + else + console.log( + ` ${written.length} files written (run.json, summary.md, step summary), ${secrets.length} known secret value(s) checked, no leak (${env.open || env.pam || env.datasync ? 'loaded keys included' : 'no keys loaded, fake values only'})`, + ); + // The per snippet id summary (snippet-summary.cjs): right status per id, and the same leak rule as the other files. + { + const { renderSnippetSummary } = (await import(pathToFileURL(path.join(SUITE_DIR, 'harness', 'snippet-summary.cjs')).href)) as { + renderSnippetSummary: (r: unknown[], m: unknown) => string; + }; + const manifest = { + leaky: { file: 'x.ts', step: 'leaky', role: 'open', phase: 'P4', mode: 'live', reason: null }, + ghost: { file: 'x.ts', step: 'ghost', role: 'open', phase: 'P4', mode: 'live', reason: null }, + typed: { file: 'b.ts', step: null, role: null, phase: null, mode: 'compile-only', reason: 'browser-only' }, + }; + const md = redact(renderSnippetSummary([{ shard: 'open', report }], manifest)); + if (!/\| `leaky` \| FAILED \|/.test(md) || !/\| `ghost` \| NOT RUN \|/.test(md) || !/\| `typed` \| type checked only/.test(md)) + fail('snippet summary: a failed, a not-run and a compile-only id must each show their own result'); + if (!/\| 0 \| 1 \| 0 \| 1 \| 1 \| 0 \|/.test(md)) fail('snippet summary: counts row is wrong'); + if (secrets.some((x) => md.includes(x)) || md.includes('0123abcd')) fail('the snippet summary leaks key values'); + else console.log(' snippet summary: failed, not-run and type-checked ids shown, no leak'); + } + fs.rmSync(dir, { recursive: true, force: true }); + } + + heading('10. No key in any output (stdout, stderr, crashes, shortened values)'); + { + // A child process with a fake secret that has every character a URL encodes. It writes the secret in each way the + // security review named: split across writes, as a Uint8Array, URL-encoded, inside an object, in a URL-encoded token, + // and finally in an uncaught exception outside Mocha. Nothing of it may reach the child's stdout or stderr. + const secret = 'fake-Secret/Key+With=Specials0123456789'; + const token = `p0F2AkF0Gl${'A'.repeat(12)}%2B${'B'.repeat(20)}`; + // A token granted with authorizedUserId has one more CBOR field, so it starts with `qEF2` instead of `p0F2`. + const userToken = `qEF2AkF0GmkKbpZDdHRsAUNyZXOl${'C'.repeat(24)}`; + const script = ` + const { installRedaction } = require(${JSON.stringify(path.join(SUITE_DIR, 'harness', 'redact.cjs'))}); + installRedaction(); + const s = process.env.DS_SECRET_KEY; + process.stdout.write('split ' + s.slice(0, 9)); + process.stdout.write(s.slice(9) + '\\n'); + process.stdout.write(new TextEncoder().encode('bytes ' + s + '\\n')); + console.log('url https://x/?k=' + encodeURIComponent(s)); + console.log('object', { config: { secretKey: s } }); + console.error('token ${token}'); + console.error('user token ${userToken} encoded ' + encodeURIComponent('${userToken}/x+y')); + setTimeout(() => { throw new Error('boom ' + s); }, 10); + `; + const run = spawnSync(process.execPath, ['-e', script], { + env: { PATH: process.env.PATH, DS_SECRET_KEY: secret }, + encoding: 'utf8', + }); + const output = `${run.stdout}${run.stderr}`; + const forms = [ + secret, + encodeURIComponent(secret), + encodeURIComponent(secret).toLowerCase(), + secret.slice(9), + token.slice(3), + userToken.slice(4), + ]; + const leaked = forms.filter((f) => output.includes(f)).length; + if (leaked > 0) fail(`a child process printed ${leaked} form(s) of a secret`); + if (!/split \[redacted\]/.test(output) || !/bytes \[redacted\]/.test(output) || !/k=\[redacted\]/.test(output)) + fail('split, byte array or URL-encoded output was not redacted in place'); + if (!/token \[token\]/.test(output)) fail('a URL-encoded token was not masked'); + if (!/user token \[token\] encoded \[token\]/.test(output)) + fail('a token with an authorized user id (qEF2) was not masked'); + if (run.status !== 1 || !/boom \[redacted\]/.test(output)) + fail(`an uncaught exception must print redacted and exit 1, got exit ${run.status}`); + // Shortened values: the recorder cuts long values for the report. It must redact before it cuts. + addSecrets([secret]); + const { short } = await import('./recorder.js'); + const cut = short({ config: { secretKey: secret } }, 30); + if (cut.includes(secret.slice(0, 12))) fail('the recorder cut a secret before redacting it'); + if (leaked === 0) + console.log( + ' split writes, byte arrays, URL-encoded keys and tokens, objects, uncaught exceptions and shortened values are all redacted', + ); + } + + heading('11. The "keyset closed" probe counts only a 403 as refused'); + { + const { tokenlessAccess } = await import('./revoke.js'); + const denied = Object.assign(new Error('Forbidden'), { + status: { category: 'PNAccessDeniedCategory', statusCode: 403 }, + }); + const network = Object.assign(new Error('fetch failed'), { status: { category: 'PNNetworkIssuesCategory' } }); + const timeout = Object.assign(new Error('timed out'), { status: { category: 'PNTimeoutCategory' } }); + const server = Object.assign(new Error('Internal'), { status: { statusCode: 500 } }); + const bad = Object.assign(new Error('Bad request'), { status: { statusCode: 400 } }); + // A stand-in client: each call fails with the given error, or succeeds when it is undefined. + const fake = (publishError?: Error, readError?: Error) => + ({ + publish: async () => { + if (publishError) throw publishError; + return { timetoken: '1' }; + }, + fetchMessages: async () => { + if (readError) throw readError; + return { channels: {} }; + }, + }) as never; + const cases: Array<[string, Error | undefined, Error | undefined, 'closed' | 'open' | 'unknown']> = [ + ['both refused with 403', denied, denied, 'closed'], + ['read open (application-level read grant)', denied, undefined, 'open'], + ['read network error', denied, network, 'unknown'], + ['read timeout', denied, timeout, 'unknown'], + ['read 500', denied, server, 'unknown'], + ['read 400', denied, bad, 'unknown'], + ['publish network error', network, denied, 'unknown'], + ]; + for (const [label, publishError, readError, expected] of cases) { + const seen = await tokenlessAccess(fake(publishError, readError), 'probe'); + const verdict = seen.open.length > 0 ? 'open' : seen.unknown.length > 0 ? 'unknown' : 'closed'; + if (verdict !== expected) fail(`closure probe, ${label}: expected ${expected}, got ${verdict}`); + } + console.log( + ` ${cases.length} cases: only a 403 on both probes reads as closed, network errors, timeouts, 5xx and 400 read as unknown`, + ); + } + + heading('Result'); + console.log(failed ? 'dry run FAILED' : 'dry run ok'); + return failed ? 1 : 0; +} + +/** `docs-snippets:manifest`: writes coverage.json, or with --check verifies it. Reports docs ids either way. */ +async function manifest(): Promise { + const lint = lintApp(SUITE_DIR); + if (lint.errors.length > 0) { + printLint(lint); + return 1; + } + const runId = randomRunId(); + const staged = stage(SUITE_DIR, runId); + const plan = await stagedPlan(staged.runDir, loadEnv({}), runId); + fs.rmSync(staged.runDir, { recursive: true, force: true }); + const { manifest: built, problems } = buildManifest(lint, plan.registry); + const file = path.join(SUITE_DIR, 'coverage.json'); + const check = flags.has('--check'); + if (check) { + if (!fs.existsSync(file)) problems.push('coverage.json does not exist, run npm run docs-snippets:manifest'); + else if (fs.readFileSync(file, 'utf8') !== serialize(built)) + problems.push('coverage.json is stale, run npm run docs-snippets:manifest'); + } else if (problems.length === 0) { + fs.writeFileSync(file, serialize(built)); + } + const modes = Object.values(built.snippets).reduce>( + (acc, e) => ({ ...acc, [e.mode]: (acc[e.mode] ?? 0) + 1 }), + {}, + ); + console.log( + `manifest: ${Object.keys(built.snippets).length} entries (${Object.entries(modes) + .map(([m, n]) => `${m} ${n}`) + .join(', ')})${check ? '' : problems.length ? '' : ', written to docs-snippets/coverage.json'}`, + ); + + const docsDir = process.env.DOCS_SNIPPETS_DOCS_DIR || path.resolve(SUITE_DIR, '..', '..', 'documentation'); + const docs = docsIds(docsDir); + if (!docs) + console.log(`docs ids: skipped, ${docsDir}/docs/sdks/javascript does not exist (set DOCS_SNIPPETS_DOCS_DIR)`); + else { + const missing = [...docs.ids.keys()].filter((id) => !built.snippets[id]).sort(); + const unused = Object.keys(built.snippets) + .filter((id) => !docs.ids.has(id)) + .sort(); + console.log( + `docs ids: ${docs.ids.size} ids in ${docs.files} files of ${docsDir}/docs/sdks/javascript (report only)`, + ); + console.log( + ` docs ids missing from the manifest (${missing.length}): ${missing.map((id) => `${id} (${docs.ids.get(id)?.[0]})`).join(', ') || 'none'}`, + ); + console.log(` manifest ids no docs page uses (${unused.length}): ${unused.join(', ') || 'none'}`); + } + problems.forEach((p) => console.error(`manifest: ${p}`)); + return problems.length > 0 ? 1 : 0; +} + +async function main(): Promise { + // Every command masks keys in its output, also the ones that only load keys for a self-test. + installRedaction(); + switch (command) { + case 'live': + return live(); + case 'dry': + return dryRun(); + case 'manifest': + return manifest(); + case 'stage': { + const result = stage(SUITE_DIR, loadEnv().runId ?? randomRunId(), { + mutate: process.env.DOCS_SNIPPETS_MUTATE || undefined, + }); + console.log(`staged ${result.filesCopied} files, ${result.rewrites.length} rewrites into ${result.runDir}`); + result.problems.forEach((p) => console.error(p)); + return result.problems.length > 0 ? 1 : 0; + } + case 'lint': { + const result = lintApp(SUITE_DIR); + printLint(result); + return result.errors.length > 0 ? 1 : 0; + } + case 'janitor': { + installRedaction(); + const env = loadEnv(); + console.log(describeRoles(env)); + if (!env.open && !env.pam && !env.datasync) return env.ci ? 1 : 0; + // A fresh run id of its own: only leftovers of other runs older than six hours are swept, plus the grants. + const report = await runJanitor(env, randomRunId(), new Date(Date.now() - 60 * 60 * 1000)); + report.log.forEach((l) => console.log(l)); + report.critical.forEach((c) => console.error(`CRITICAL ${c}`)); + return report.critical.length > 0 ? 1 : 0; + } + default: + console.error(`unknown command "${command}". Use live, dry, manifest, janitor, stage or lint.`); + return 2; + } +} + +main().then( + (code) => exitWhenIdle(code), + (error) => { + console.error(error); + process.exit(1); + }, +); diff --git a/docs-snippets/harness/env.ts b/docs-snippets/harness/env.ts new file mode 100644 index 000000000..9e4349f62 --- /dev/null +++ b/docs-snippets/harness/env.ts @@ -0,0 +1,324 @@ +/** + * The environment contract of the suite: keysets per role, the admin key, run id, log level, skip budget, required + * features, the strict flag and the CI flag. + * + * One fixed set of names. The harness reads only these, wherever the value came from: + * + * role env names used for + * open SUBSCRIBE_KEY, PUBLISH_KEY most samples (no Access Manager) + * pam PAM_SUBSCRIBE_KEY, PAM_PUBLISH_KEY, PAM_SECRET_KEY Access Manager samples and 403 branches + * datasync DS_SUBSCRIBE_KEY, DS_PUBLISH_KEY, DS_SECRET_KEY DataSync samples + * admin PUBNUB_ADMIN_API_KEY DataSync class setup and keyset feature reads + * + * Two sources fill them. In CI the workflow sets them from CI secrets. Locally an env file fills them: `docs-snippets/.env.local` + * by default (copy `.env.example`), or the file `DOCS_SNIPPETS_ENV_FILE` names. It is read in memory and never copied or + * rewritten. It may use the names above or the older `PN_DOCS_*` names, which are mapped as `FILE_ALIASES` says. Real environment variables always win, so a + * CI run never reads a stray local file value. + * + * Output rule: nothing here prints a value or a prefix of one. `describeRoles` says "set" or "missing" per role. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { addSecrets } from './redact.cjs'; + +/** + * The default local env file: `docs-snippets/.env.local`, ignored by git. Copy `docs-snippets/.env.example` there and fill in + * your own keys. `DOCS_SNIPPETS_ENV_FILE` points at another file instead. The CLI resolves this once and passes it on, because + * the staged copy under `.run//` sits in another directory. + */ +export const DEFAULT_ENV_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.env.local'); + +/** The roles a step can run as. `pam` and `datasync` may point at the same keyset (they do locally). */ +export type Role = 'open' | 'pam' | 'datasync'; +export const ROLES: readonly Role[] = ['open', 'pam', 'datasync']; + +/** Key names per role, in the order publish, subscribe, secret. */ +export const KEY_NAMES = { + open: { publish: 'PUBLISH_KEY', subscribe: 'SUBSCRIBE_KEY' }, + pam: { publish: 'PAM_PUBLISH_KEY', subscribe: 'PAM_SUBSCRIBE_KEY', secret: 'PAM_SECRET_KEY' }, + datasync: { publish: 'DS_PUBLISH_KEY', subscribe: 'DS_SUBSCRIBE_KEY', secret: 'DS_SECRET_KEY' }, +} as const; +export const ADMIN_KEY = 'PUBNUB_ADMIN_API_KEY'; + +/** Every env name that holds a secret. The redaction (redact.cjs) masks their values. */ +export const SECRET_NAMES: readonly string[] = [ + KEY_NAMES.open.publish, + KEY_NAMES.open.subscribe, + KEY_NAMES.pam.publish, + KEY_NAMES.pam.subscribe, + KEY_NAMES.pam.secret, + KEY_NAMES.datasync.publish, + KEY_NAMES.datasync.subscribe, + KEY_NAMES.datasync.secret, + ADMIN_KEY, +]; + +/** Control variables. All start with `DOCS_SNIPPETS_`. */ +export const ENV_VARS = { + envFile: 'DOCS_SNIPPETS_ENV_FILE', + runId: 'DOCS_SNIPPETS_RUN_ID', + logLevel: 'DOCS_SNIPPETS_LOG_LEVEL', + skipBudget: 'DOCS_SNIPPETS_SKIP_BUDGET', + requireFeatures: 'DOCS_SNIPPETS_REQUIRE_FEATURES', + strict: 'DOCS_SNIPPETS_STRICT', + shard: 'DOCS_SNIPPETS_SHARD', + openKeysetId: 'DOCS_SNIPPETS_OPEN_KEYSET_ID', + pamKeysetId: 'DOCS_SNIPPETS_PAM_KEYSET_ID', + datasyncKeysetId: 'DOCS_SNIPPETS_DS_KEYSET_ID', +} as const; + +/** + * The only place that knows the older `PN_DOCS_*` env names. A local env file that uses them works unchanged: + * its open keyset fills `open`, its secured keyset fills both `pam` and `datasync`. + */ +export const FILE_ALIASES: Readonly> = { + PUBLISH_KEY: ['PN_DOCS_OPEN_PUBLISH_KEY'], + SUBSCRIBE_KEY: ['PN_DOCS_OPEN_SUBSCRIBE_KEY'], + PAM_PUBLISH_KEY: ['PN_DOCS_SECURED_PUBLISH_KEY'], + PAM_SUBSCRIBE_KEY: ['PN_DOCS_SECURED_SUBSCRIBE_KEY'], + PAM_SECRET_KEY: ['PN_DOCS_SECURED_SECRET_KEY'], + DS_PUBLISH_KEY: ['PN_DOCS_SECURED_PUBLISH_KEY'], + DS_SUBSCRIBE_KEY: ['PN_DOCS_SECURED_SUBSCRIBE_KEY'], + DS_SECRET_KEY: ['PN_DOCS_SECURED_SECRET_KEY'], +}; + +export interface OpenKeyset { + publishKey: string; + subscribeKey: string; +} + +export interface SecuredKeyset extends OpenKeyset { + secretKey: string; +} + +export type LogLevelName = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'none'; + +export interface Env { + open?: OpenKeyset; + pam?: SecuredKeyset; + datasync?: SecuredKeyset; + /** Admin API key (Service Integration). Optional. Only provisioning and the read-only preflight use it. */ + adminKey?: string; + /** Missing env names per role. Empty when the role is usable. */ + missing: Record; + /** True when `pam` and `datasync` are the same keyset. Compared here, never printed. */ + sharedSecured: boolean; + runId?: string; + logLevel: LogLevelName; + ci: boolean; + /** + * Most `blocked-by:*` skips a live run may report before it fails. Unset: 0 in CI and unlimited elsewhere. + */ + skipBudget: number; + skipBudgetProblem?: string; + /** Features whose `needs-feature:` skips fail the run (`DOCS_SNIPPETS_REQUIRE_FEATURES`). */ + requireFeatures: string[]; + /** `DOCS_SNIPPETS_STRICT=1`: every skip fails the run. */ + strict: boolean; + /** `DOCS_SNIPPETS_SHARD=`: the role whose steps this run selects. Undefined means all roles. */ + shard?: Role; + shardProblem?: string; + /** Where the env file came from, for the "set or missing" line. Never its content. */ + envFile?: string; + /** + * Admin Portal keyset ids per role, for the read-only feature preflight (keyset-features.ts). Not secrets. The preflight + * confirms that each id's subscribe key equals the role's key before it relies on the id. + */ + keysetIds: Partial>; +} + +/** Minimal env file reader. Values stay in memory. Real environment variables win (see `envSource`). */ +export function readDotEnv(file: string): Record { + if (!file || !fs.existsSync(file)) return {}; + const out: Record = {}; + for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line + .slice(0, eq) + .trim() + .replace(/^export\s+/, ''); + let value = line.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +/** Maps a local env file onto the suite's names. A name the file sets directly wins over an alias. */ +function mapFile(file: Record): Record { + const out: Record = {}; + for (const [name, aliases] of Object.entries(FILE_ALIASES)) { + for (const alias of aliases) if (file[alias] && !out[name]) out[name] = file[alias]; + } + for (const name of [...SECRET_NAMES, ...Object.values(ENV_VARS)]) if (file[name]) out[name] = file[name]; + return out; +} + +/** File values (mapped) overlaid by the real environment. The env file path itself comes from the real environment only. */ +export function envSource(vars: Record = process.env): Record { + const file = vars[ENV_VARS.envFile]?.trim() || (fs.existsSync(DEFAULT_ENV_FILE) ? DEFAULT_ENV_FILE : undefined); + const mapped = file ? mapFile(readDotEnv(path.resolve(file))) : {}; + const out: Record = { ...mapped }; + for (const [key, value] of Object.entries(vars)) if (value !== undefined && value !== '') out[key] = value; + return out; +} + +function pick(source: Record, names: string[]): { values: string[]; missing: string[] } { + const values: string[] = []; + const missing: string[] = []; + for (const name of names) { + const value = source[name]?.trim(); + if (value) values.push(value); + else missing.push(name); + } + return { values, missing }; +} + +/** + * Reads and validates the environment. Pure with respect to `source`, which is how the dry run builds CI, budget and + * feature scenarios without touching the real environment. Registers every loaded secret with the redaction. + */ +export function loadEnv(source: Record = envSource()): Env { + const open = pick(source, [KEY_NAMES.open.publish, KEY_NAMES.open.subscribe]); + const pam = pick(source, [KEY_NAMES.pam.publish, KEY_NAMES.pam.subscribe, KEY_NAMES.pam.secret]); + const ds = pick(source, [KEY_NAMES.datasync.publish, KEY_NAMES.datasync.subscribe, KEY_NAMES.datasync.secret]); + const adminKey = source[ADMIN_KEY]?.trim() || undefined; + addSecrets([...open.values, ...pam.values, ...ds.values, adminKey]); + + const ci = Boolean(source.CI || source.GITHUB_ACTIONS); + // An unknown level falls back to `none`, so a typo cannot make the SDK log its configuration (keys included). + const rawLevel = (source[ENV_VARS.logLevel] ?? '').toLowerCase(); + const levels: LogLevelName[] = ['trace', 'debug', 'info', 'warn', 'error', 'none']; + const logLevel = (levels as string[]).includes(rawLevel) ? (rawLevel as LogLevelName) : 'none'; + + const rawBudget = source[ENV_VARS.skipBudget]?.trim(); + let skipBudget = ci ? 0 : Number.POSITIVE_INFINITY; + let skipBudgetProblem: string | undefined; + if (rawBudget) { + if (/^\d+$/.test(rawBudget)) skipBudget = Number.parseInt(rawBudget, 10); + else skipBudgetProblem = `${ENV_VARS.skipBudget}="${rawBudget}" is not a non-negative integer`; + } + + const requireFeatures = (source[ENV_VARS.requireFeatures] ?? '') + .split(',') + .map((f) => f.trim().toLowerCase()) + .filter(Boolean); + + const rawShard = source[ENV_VARS.shard]?.trim().toLowerCase(); + let shard: Role | undefined; + let shardProblem: string | undefined; + if (rawShard) { + if ((ROLES as readonly string[]).includes(rawShard)) shard = rawShard as Role; + else shardProblem = `${ENV_VARS.shard}="${rawShard}" is not one of ${ROLES.join(', ')}`; + } + + let runId = source[ENV_VARS.runId]?.trim().toLowerCase(); + if (runId && !/^[0-9a-z]{6}$/.test(runId)) { + // Reduce any other value to six base36 characters, so names stay valid for DataSync. + runId = Number.parseInt(runId.replace(/\D/g, '') || '0', 10) + .toString(36) + .padStart(6, '0') + .slice(-6); + } + + const secured = (r: { values: string[]; missing: string[] }): SecuredKeyset | undefined => + r.missing.length === 0 ? { publishKey: r.values[0], subscribeKey: r.values[1], secretKey: r.values[2] } : undefined; + const pamKeys = secured(pam); + const dsKeys = secured(ds); + return { + open: open.missing.length === 0 ? { publishKey: open.values[0], subscribeKey: open.values[1] } : undefined, + pam: pamKeys, + datasync: dsKeys, + adminKey, + missing: { open: open.missing, pam: pam.missing, datasync: ds.missing }, + sharedSecured: Boolean( + pamKeys && dsKeys && pamKeys.subscribeKey === dsKeys.subscribeKey && pamKeys.publishKey === dsKeys.publishKey, + ), + runId, + logLevel, + ci, + skipBudget, + skipBudgetProblem, + requireFeatures, + strict: source[ENV_VARS.strict] === '1', + shard, + shardProblem, + envFile: source[ENV_VARS.envFile]?.trim() || undefined, + keysetIds: { + open: source[ENV_VARS.openKeysetId]?.trim() || undefined, + pam: source[ENV_VARS.pamKeysetId]?.trim() || undefined, + datasync: source[ENV_VARS.datasyncKeysetId]?.trim() || undefined, + }, + }; +} + +/** Secured keysets to guard, without duplicates (pam and datasync may be one keyset). */ +export function securedKeysets(env: Env): Array<{ role: 'pam' | 'datasync'; keys: SecuredKeyset }> { + const out: Array<{ role: 'pam' | 'datasync'; keys: SecuredKeyset }> = []; + if (env.pam) out.push({ role: 'pam', keys: env.pam }); + if (env.datasync && !env.sharedSecured) out.push({ role: 'datasync', keys: env.datasync }); + return out; +} + +/** The loader's only output: "set" or "missing" per role. Never a value, never a prefix of one. */ +export function describeRoles(env: Env): string { + const parts = ROLES.map((role) => `${role} ${env[role] ? 'set' : 'missing'}`); + parts.push(`admin ${env.adminKey ? 'set' : 'missing'}`); + const from = env.envFile ? `, env file ${env.envFile}` : ''; + return `keysets: ${parts.join(', ')}${env.sharedSecured ? ' (pam and datasync are one keyset)' : ''}${from}`; +} + +/** + * Run ids carry their start time, so the janitor can tell a crashed run from a running one by pattern alone. + * Five base36 characters count 4 second ticks since 2026-01-01 and one random character keeps runs of the same tick apart. + * In CI a seventh character is not possible (DataSync and every name assume six), so CI runs mix the GitHub run id, attempt + * and shard into the random part (see `ciRunId`). + */ +const RUN_ID_EPOCH = Date.UTC(2026, 0, 1); +const RUN_ID_TICK_MS = 4_000; +const RUN_ID_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; + +export function randomRunId(now: number = Date.now(), salt?: string): string { + const ticks = Math.max(0, Math.floor((now - RUN_ID_EPOCH) / RUN_ID_TICK_MS)) % 36 ** 5; + let last = Math.floor(Math.random() * 36); + if (salt) { + let hash = 0; + for (const ch of salt) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; + last = hash % 36; + } + return ticks.toString(36).padStart(5, '0') + last.toString(36); +} + +/** + * Run id for a CI job: the time tick plus one character derived from GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT and the shard. Three + * shards start in the same tick, so the shard alone must separate them: the salt guarantees different characters for the + * three shard names of one run attempt (checked by the dry run). + */ +export function ciRunId( + vars: Record, + shard: string | undefined, + now: number = Date.now(), +): string { + const base = randomRunId(now).slice(0, 5); + const shardIndex = shard ? Math.max(0, (ROLES as readonly string[]).indexOf(shard)) : 3; + let hash = 0; + for (const ch of `${vars.GITHUB_RUN_ID ?? ''}.${vars.GITHUB_RUN_ATTEMPT ?? ''}`) + hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; + // 9 slots of 4 characters: the run and attempt pick a slot, the shard picks a character inside it. + const slot = hash % 9; + return base + (slot * 4 + shardIndex).toString(36); +} + +/** Age of a run id in ms, or undefined when it does not decode to the last year. Hand-written ids are never swept. */ +export function runIdAgeMs(runId: string, now: number = Date.now()): number | undefined { + if (!/^[0-9a-z]{6}$/.test(runId)) return undefined; + const started = RUN_ID_EPOCH + Number.parseInt(runId.slice(0, 5), 36) * RUN_ID_TICK_MS; + const age = now - started; + return age >= -RUN_ID_TICK_MS && age <= RUN_ID_MAX_AGE_MS ? Math.max(0, age) : undefined; +} diff --git a/docs-snippets/harness/guard.ts b/docs-snippets/harness/guard.ts new file mode 100644 index 000000000..3fca0e6ec --- /dev/null +++ b/docs-snippets/harness/guard.ts @@ -0,0 +1,56 @@ +/** + * Failure-on-unhandled-rejection guard. Snippets let errors throw, so a rejection nobody awaits means a step + * did something the harness did not assert. The guard records it against the running step and the step runner + * fails that step after the body returns. + */ + +// Module-level on purpose: the process-wide `unhandledRejection` event has no idea which step is running, so the step runner +// drains this list before and after each step to assign blame. +const recorded: Array<{ kind: string; error: unknown }> = []; +let installed = false; + +/** Idempotent. Without a handler Node would crash the whole run on an unhandled rejection. Here it fails one step. */ +export function installGuard(): void { + if (installed) return; + installed = true; + process.on('unhandledRejection', (error) => { + recorded.push({ kind: 'unhandledRejection', error }); + }); +} + +/** Returns and clears errors recorded since the last call. */ +export function drainGuard(): Array<{ kind: string; error: unknown }> { + return recorded.splice(0, recorded.length); +} + +/** One line for reports. Falls back to JSON for non-Error throws (the SDK sometimes rejects with a plain status object). */ +export function describeError(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}`; + try { + return typeof error === 'string' ? error : JSON.stringify(error); + } catch { + return String(error); + } +} + +// The subset of the SDK's rejection shape that carries a reason. +interface SdkStatus { + category?: unknown; + statusCode?: unknown; + errorData?: { error_message?: unknown; message?: unknown; error?: { message?: unknown } } | unknown; + message?: unknown; +} + +/** + * The SDK reports most service failures as "REST API request processing error, check status for details". + * This appends the category, status code and the service's own message, so a report line explains itself. + */ +export function explainError(error: unknown): void { + if (!(error instanceof Error)) return; + const status = (error as Error & { status?: SdkStatus }).status; + if (!status || typeof status !== 'object') return; + const data = (status.errorData ?? {}) as { error_message?: unknown; message?: unknown; error?: { message?: unknown } }; + const detail = data.error_message ?? data.message ?? data.error?.message ?? status.message; + const parts = [String(status.category ?? ''), String(status.statusCode ?? '')].filter(Boolean).join(' '); + error.message = `${error.message} [${parts}${detail ? `: ${String(detail)}` : ''}]`; +} diff --git a/docs-snippets/harness/janitor-datasync.ts b/docs-snippets/harness/janitor-datasync.ts new file mode 100644 index 000000000..2c28e9b0c --- /dev/null +++ b/docs-snippets/harness/janitor-datasync.ts @@ -0,0 +1,134 @@ +import PubNub from 'pubnub'; +import { hasRunSuffix } from '../fixtures/names.js'; +import type { Env } from './env.js'; + +/** + * Janitor for DataSync objects on the datasync keyset (called from janitor.ts). + * + * 1. remove this run's memberships, relationships, entities, users and channels (ids carry `-r`) + * 2. sweep objects with any run suffix that are older than six hours (crashed earlier runs) + * 3. report residue: objects created during the run window without a run suffix + * + * It uses the secret key client, lists every kind with paging and removes in dependency order. Classes that do + * not exist yet (`product`, `ProductOwner`) are skipped quietly. Objects that carry a run suffix but belong to the + * Access Manager token tests are cleaned the same way, because they live on the same keyset. + */ + +// Same six hour rule as janitor.ts. MAX_PAGES bounds a sweep if the service keeps returning cursors. +const SIX_HOURS = 6 * 60 * 60 * 1000; +const MAX_PAGES = 50; + +export interface DataSyncJanitorReport { + log: string[]; + residue: string[]; +} + +interface Listed { + id: string; + createdAt?: string; +} + +interface Page { + data: Listed[]; + meta?: { has_next?: boolean; next_cursor?: string | null }; +} + +async function listAll(fetchPage: (cursor?: string) => Promise): Promise { + const out: Listed[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const response = await fetchPage(cursor); + out.push(...response.data.map((o) => ({ id: o.id, createdAt: o.createdAt }))); + cursor = response.meta?.next_cursor ?? undefined; + if (!response.meta?.has_next || !cursor) break; + } + return out; +} + +// DS-0100 is the service code for an unknown class. `product` and `ProductOwner` are provisioned by hand (provision/), so a +// keyset without them is normal and not a janitor failure. +function missingClass(error: unknown): boolean { + const status = (error as { status?: { statusCode?: number; errorData?: unknown } } | undefined)?.status; + return status?.statusCode === 404 && /DS-0100/.test(JSON.stringify(status.errorData ?? {})); +} + +export async function sweepDataSync(env: Env, runId: string, since: Date): Promise { + const log: string[] = []; + const residue: string[] = []; + if (!env.datasync) { + log.push('datasync keyset not configured, no DataSync objects to clean'); + return { log, residue }; + } + const client = new PubNub({ ...env.datasync, userId: `janitor-ds-r${runId}`, logLevel: PubNub.LogLevel.None, heartbeatInterval: 0 }); + const ds = client.dataSync; + const now = Date.now(); + const mine = new RegExp(`-r${runId}(?=\\.|$)`); + + // Delete this run's objects and any old suffixed ones. Everything else is left alone (a human may own it). + const doomed = (o: Listed): boolean => { + if (mine.test(o.id)) return true; + const created = o.createdAt ? Date.parse(o.createdAt) : 0; + return hasRunSuffix(o.id) && created > 0 && now - created > SIX_HOURS; + }; + const note = (kind: string, o: Listed): void => { + const created = o.createdAt ? Date.parse(o.createdAt) : 0; + if (!hasRunSuffix(o.id) && created >= since.getTime()) residue.push(`dataSync ${kind}:${o.id}`); + }; + + // Dependency order: memberships and relationships reference users, channels and entities, so they go first. + const kinds: Array<{ + kind: string; + list: () => Promise; + remove: (id: string) => Promise; + optionalClass?: boolean; + }> = [ + { kind: 'membership', list: () => listAll((cursor) => ds.getMemberships({ limit: 100, cursor })), remove: (id) => ds.removeMembership({ id }) }, + { + kind: 'relationship', + list: () => listAll((cursor) => ds.getRelationships({ class: 'ProductOwner', limit: 100, cursor })), + remove: (id) => ds.removeRelationship({ id }), + optionalClass: true, + }, + { + kind: 'entity', + list: () => listAll((cursor) => ds.getEntities({ class: 'product', limit: 100, cursor })), + remove: (id) => ds.removeEntity({ id }), + optionalClass: true, + }, + { kind: 'user', list: () => listAll((cursor) => ds.getUsers({ limit: 100, cursor })), remove: (id) => ds.removeUser({ id }) }, + { kind: 'channel', list: () => listAll((cursor) => ds.getChannels({ limit: 100, cursor })), remove: (id) => ds.removeChannel({ id }) }, + ]; + + try { + for (const { kind, list, remove, optionalClass } of kinds) { + let objects: Listed[]; + try { + objects = await list(); + } catch (error) { + if (optionalClass && missingClass(error)) { + log.push(`DataSync ${kind}: class not provisioned, skipped`); + } else { + log.push(`DataSync ${kind}: listing failed, skipped (${(error as Error).message ?? error})`); + } + continue; + } + let removed = 0; + for (const object of objects) { + if (doomed(object)) { + try { + await remove(object.id); + removed += 1; + } catch { + // Already removed by a cascade (deleting a user removes its memberships). + } + } else { + note(kind, object); + } + } + log.push(`DataSync ${kind}s removed: ${removed}`); + } + } finally { + client.destroy(); + } + return { log, residue }; +} diff --git a/docs-snippets/harness/janitor.ts b/docs-snippets/harness/janitor.ts new file mode 100644 index 000000000..f382b1fae --- /dev/null +++ b/docs-snippets/harness/janitor.ts @@ -0,0 +1,255 @@ +import PubNub from 'pubnub'; +import { allGroupNames, hasRunSuffix, names, SUFFIX_PATTERN, suffixName } from '../fixtures/names.js'; +import { ROLES, runIdAgeMs, securedKeysets, type Env, type Role } from './env.js'; +import { sweepDataSync } from './janitor-datasync.js'; +import { revokeApplicationGrantOnKeyset } from './revoke.js'; + +/** + * Janitor. Runs in the Mocha root `after` hook and from `npm run test:docs-snippets:janitor`. + * + * Phase 1 scope: + * 1. delete this run's channel groups (registered names, 404 ignored) + * 2. remove this run's App Context users and channels on the open keyset + * 3. sweep App Context objects with a run suffix older than six hours (crashed earlier runs) + * 4. report residue: App Context objects created or updated during the run window without a run suffix + * + * Also removes files uploaded to the file sharing channels and push registrations of the registered device tokens, + * and revokes the application-level Access Manager grant on the pam and datasync keysets (fail-safe, see revoke.ts). + * + * Crashed-run sweep by pattern: run ids carry their start time (env.ts). Every run id found on a channel group or an + * App Context object that is older than six hours, and is not this run, gets the same per-run cleanup as this run: + * its channel groups, files on the file sharing channels and push registrations. The ids are printed first. + * DataSync objects on the datasync keyset are cleaned by janitor-datasync.ts. + * Messages cannot be listed by channel, so history residue relies on 1-day retention. + */ + +// Why six hours: longer than any single run (the full suite takes minutes), so a run id this old cannot belong to a live +// run, yet short enough that a crash is cleaned the same day. +const SIX_HOURS = 6 * 60 * 60 * 1000; +/** Channels the file sharing samples upload to. */ +const FILE_CHANNELS = ['my_channel', 'awesomeChannel', 'preflight_channel']; + +/** `log` lines are printed. `residue` and `critical` make the run fail (see mocha-entry.ts). */ +export interface JanitorReport { + log: string[]; + residue: string[]; + /** Cleanup that failed in a way that leaves the keysets in an unsafe state. The run must fail. */ + critical: string[]; +} + +interface ObjectLike { + id: string; + updated?: string; +} + +/** + * Cleans up after one run and after crashed ones. `since` is the run start, it bounds which unsuffixed objects count as + * residue. Never throws for ordinary cleanup failures (a missing object is fine), only reports `critical` for the revoke. + */ +export async function runJanitor(env: Env, runId: string, since: Date, roles: readonly Role[] = ROLES): Promise { + const log: string[] = []; + const residue: string[] = []; + const critical: string[] = []; + // Only the keysets of the roles this run used. CI runs the shards in parallel, and an open shard must never revoke the + // application-level grant a pam step is testing at that moment. + const uses = (role: Role): boolean => roles.includes(role) || (role === 'pam' && env.sharedSecured && roles.includes('datasync')); + // Safety first: never leave a secured keyset open to strangers. pam and datasync may be one keyset, it is revoked once. + for (const { role, keys } of securedKeysets(env).filter((k) => uses(k.role))) { + try { + await revokeApplicationGrantOnKeyset(keys, `r${runId}`); + // The revoke call succeeded, but Access Manager (v2) needs up to about 60 seconds to apply it. The root `after` hook waits + // for it (revoke.ts, closeAndVerifyApplicationGrant). The standalone janitor command only says so. + log.push(`application-level grant on the ${role} keyset revoked (it can take up to 60 s to take effect)`); + } catch (error) { + critical.push(`${role} keyset: ${(error as Error).message}`); + } + } + // DataSync objects live on the datasync keyset, see janitor-datasync.ts. A failure here must not skip the open keyset cleanup. + if (roles.includes('datasync')) { + try { + const dataSync = await sweepDataSync(env, runId, since); + log.push(...dataSync.log); + residue.push(...dataSync.residue); + } catch (error) { + log.push(`DataSync sweep failed (${(error as Error).message ?? error})`); + } + } + if (!env.open) { + log.push('open keyset not configured, nothing to clean'); + return { log, residue, critical }; + } + // A throwaway admin client of its own: the world's clients are destroyed before the janitor runs after a signal. + const admin = new PubNub({ + publishKey: env.open.publishKey, + subscribeKey: env.open.subscribeKey, + userId: `janitor-r${runId}`, + logLevel: PubNub.LogLevel.None, + }); + + try { + // Run ids of crashed runs, discovered by pattern before anything is deleted. + const stale = new Set(); + // Collects the run id from any resource name with a `-r` suffix, if that run is old enough to be dead. + const noteStale = (id: string): void => { + const match = SUFFIX_PATTERN.exec(id); + if (!match) return; + const other = match[0].slice(2); + if (other === runId) return; + const age = runIdAgeMs(other); + if (age !== undefined && age > SIX_HOURS) stale.add(other); + }; + let groupNames: string[] = []; + try { + groupNames = (await admin.channelGroups.listGroups()).groups; + groupNames.forEach(noteStale); + } catch (error) { + log.push(`channel group listing unavailable, group sweep skipped (${(error as Error).message ?? error})`); + } + + // 1. Everything this run created, then the same for each crashed run. + log.push(`run id ${runId}`); + await cleanupRun(admin, runId, log, residue); + const objects = new Map<'uuid' | 'channel', ObjectLike[]>(); + for (const kind of ['uuid', 'channel'] as const) { + try { + const listed = await listAll(admin, kind); + objects.set(kind, listed); + listed.forEach((o) => noteStale(o.id)); + } catch (error) { + log.push(`App Context ${kind} metadata unavailable, skipped (${(error as Error).message ?? error})`); + } + } + if (stale.size > 0) log.push(`crashed runs older than six hours found by pattern: ${[...stale].join(', ')}`); + for (const id of stale) { + log.push(`sweeping run id ${id}`); + await cleanupRun(admin, id, log, residue); + } + + // 2 to 4. App Context. `mine` = this run's suffix, `old` = any suffixed object not touched for six hours, + // anything else created during this run without a suffix is residue (the snippet used an unregistered literal). + // The lookahead accepts the suffix on the first dot segment (`chats-r.room1`) or at the end of the name. + const runSuffix = new RegExp(`-r${runId}(?=\\.|$)`); + for (const kind of ['uuid', 'channel'] as const) { + const listed = objects.get(kind); + if (!listed) continue; + const now = Date.now(); + let removed = 0; + for (const object of listed) { + const updated = object.updated ? Date.parse(object.updated) : 0; + const mine = runSuffix.test(object.id); + const old = hasRunSuffix(object.id) && updated > 0 && now - updated > SIX_HOURS; + if (mine || old) { + try { + if (kind === 'uuid') await admin.objects.removeUUIDMetadata({ uuid: object.id }); + else await admin.objects.removeChannelMetadata({ channel: object.id }); + removed += 1; + } catch { + // Already gone. + } + } else if (!hasRunSuffix(object.id) && updated >= since.getTime()) { + residue.push(`${kind}:${object.id}`); + } + } + log.push(`App Context ${kind} metadata removed: ${removed}`); + } + } finally { + admin.destroy(); + } + return { log, residue, critical }; +} + +/** Channel groups, files and push registrations of one run id on the open keyset. */ +async function cleanupRun(admin: PubNub, runId: string, log: string[], residue: string[]): Promise { + const label = `[${runId}]`; + let removedGroups = 0; + for (const name of allGroupNames()) { + try { + await admin.channelGroups.deleteGroup({ channelGroup: suffixName(name, runId) }); + removedGroups += 1; + } catch { + // Missing groups are fine. + } + } + log.push(`${label} channel groups deleted: ${removedGroups}`); + + let removedFiles = 0; + for (const base of FILE_CHANNELS) { + const channel = suffixName(base, runId); + try { + const listed = await admin.listFiles({ channel, limit: 100 }); + for (const file of listed.data) { + try { + await admin.deleteFile({ channel, id: file.id, name: file.name }); + removedFiles += 1; + } catch { + // Already gone. + } + } + } catch { + // Files not enabled or the channel never had a file. + } + } + log.push(`${label} files deleted: ${removedFiles}`); + + // Push registrations. Device tokens are derived from the run id (names.ts `deviceToken`), so a crashed run's + // tokens are recomputed from its run id alone. The registrations themselves cannot be listed without the token. + // Run ids of crashed push runs are found through the `push_marker` channel metadata the mobile push page sets + // (see api-reference/mobile-push.ts), which the App Context sweep above lists by pattern. + // `niceDevice` is the docs sample's token. The suite only registers it in the harness check that proves the + // apns/apns2 rejection claim, and that check removes it again, but a crash in between is covered here. + const devices = [...names.device.map((base) => suffixName(base, runId)), suffixName('niceDevice', runId)]; + // Gateway variants the suite can register on: fcm, apns (v1) and both apns2 environments with the docs topic. + const gateways: Array<{ + label: string; + remove: (device: string) => Promise; + list: (device: string) => Promise<{ channels: string[] }>; + }> = [ + { label: 'fcm', remove: (device) => admin.push.deleteDevice({ device, pushGateway: 'fcm' }), list: (device) => admin.push.listChannels({ device, pushGateway: 'fcm' }) }, + { label: 'apns', remove: (device) => admin.push.deleteDevice({ device, pushGateway: 'apns' }), list: (device) => admin.push.listChannels({ device, pushGateway: 'apns' }) }, + ...(['production', 'development'] as const).map((environment) => ({ + label: `apns2/${environment}`, + remove: (device: string) => admin.push.deleteDevice({ device, pushGateway: 'apns2', environment, topic: 'com.example.bundle_id' }), + list: (device: string) => admin.push.listChannels({ device, pushGateway: 'apns2', environment, topic: 'com.example.bundle_id' }), + })), + ]; + let removedDevices = 0; + for (const device of devices) { + for (const gateway of gateways) { + try { + await gateway.remove(device); + removedDevices += 1; + } catch { + // Mobile push off, a token the gateway rejects (niceDevice on apns), or nothing registered. + } + } + } + log.push(`${label} push device registrations deleted (calls that succeeded): ${removedDevices}`); + // Verify, per gateway: a list call for each token must come back empty. Errors mean push is off or the token + // never registered there, which leaves nothing behind. Only a non-empty answer is residue. + for (const device of devices) { + for (const gateway of gateways) { + try { + const left = await gateway.list(device); + if (left.channels.length > 0) residue.push(`push ${gateway.label} ${device.slice(0, 8)}...: ${left.channels.join(',')}`); + } catch { + // Nothing to list. + } + } + } +} + +// Pages through App Context (100 per page, at most 20 pages) so cleanup finds objects beyond the first page. +async function listAll(admin: PubNub, kind: 'uuid' | 'channel'): Promise { + const out: ObjectLike[] = []; + let next: string | undefined; + for (let page = 0; page < 20; page += 1) { + const response = + kind === 'uuid' + ? await admin.objects.getAllUUIDMetadata({ limit: 100, page: next ? { next } : undefined }) + : await admin.objects.getAllChannelMetadata({ limit: 100, page: next ? { next } : undefined }); + out.push(...response.data.map((d) => ({ id: d.id, updated: d.updated }))); + next = response.next; + if (!next || response.data.length === 0) break; + } + return out; +} diff --git a/docs-snippets/harness/keyset-features.ts b/docs-snippets/harness/keyset-features.ts new file mode 100644 index 000000000..ffe722a4a --- /dev/null +++ b/docs-snippets/harness/keyset-features.ts @@ -0,0 +1,103 @@ +/** + * Read-only keyset feature check through the Admin API. It never changes a keyset, an app or a key. + * + * With `PUBNUB_ADMIN_API_KEY` and a keyset id per role (`DOCS_SNIPPETS_OPEN_KEYSET_ID`, `DOCS_SNIPPETS_PAM_KEYSET_ID`, + * `DOCS_SNIPPETS_DS_KEYSET_ID`), the preflight reads `GET /v2/keysets/{id}` and `GET /v2/keysets/{id}/config` (documented in + * the docs repository, docs/admin-api/get-keyset-by-id.api.mdx and get-keyset-configuration.api.mdx). It first confirms + * that the keyset of that id has the role's subscribe key. The comparison happens here and only its result is reported, never + * a key or part of one. Then it names every feature the role needs and the keyset lacks. + * + * Without the key or the id, `readFeatures` returns `skipped` and the preflight probes with the SDK only. + */ +import type { Env, Role } from './env.js'; + +const BASE = 'https://admin-api.pubnub.com/v2/keysets'; +const API_VERSION = '2026-09-17'; + +/** The part of the keyset configuration the suite needs. Field names as the Admin API returns them. */ +export interface KeysetConfig { + accessManager?: { enabled?: boolean; tokenRevoke?: boolean }; + files?: { enabled?: boolean }; + streamController?: { enabled?: boolean; wildcardSubscribe?: boolean }; + appContext?: { + enabled?: boolean; + userMetadataEvents?: boolean; + channelMetadataEvents?: boolean; + membershipEvents?: boolean; + referentialIntegrity?: boolean; + disallowGetAllChannelMetadata?: boolean; + disallowGetAllUserMetadata?: boolean; + }; + dataSync?: { enabled?: boolean }; + messagePersistence?: { enabled?: boolean; deleteFromHistory?: boolean }; + presence?: { enabled?: boolean; rules?: Array<{ pattern?: string; tracking?: { enabled?: boolean } }> }; +} + +export type FeatureRead = + | { status: 'skipped'; why: string } + | { status: 'mismatch'; why: string } + | { status: 'error'; why: string } + | { status: 'read'; config: KeysetConfig }; + +async function get(url: string, apiKey: string): Promise<{ status: number; json: unknown }> { + const response = await fetch(url, { headers: { Authorization: apiKey, 'PubNub-Version': API_VERSION } }); + const text = await response.text(); + try { + return { status: response.status, json: text ? JSON.parse(text) : {} }; + } catch { + return { status: response.status, json: {} }; + } +} + +/** Reads a role's keyset configuration. Never returns or logs key material. */ +export async function readFeatures(env: Env, role: Role): Promise { + const keys = env[role]; + const id = env.keysetIds[role]; + if (!env.adminKey) return { status: 'skipped', why: 'PUBNUB_ADMIN_API_KEY is not set' }; + if (!id) return { status: 'skipped', why: `no keyset id for role ${role}` }; + if (!keys) return { status: 'skipped', why: `role ${role} has no keys` }; + const keyset = await get(`${BASE}/${encodeURIComponent(id)}`, env.adminKey); + if (keyset.status !== 200) return { status: 'error', why: `reading keyset ${id} answered HTTP ${keyset.status}` }; + const subscribeKey = (keyset.json as { subscribeKey?: unknown }).subscribeKey; + // The only comparison of key material in this module. Only the boolean leaves it. + if (subscribeKey !== keys.subscribeKey) + return { status: 'mismatch', why: `keyset ${id} does not hold the ${role} subscribe key` }; + const config = await get(`${BASE}/${encodeURIComponent(id)}/config`, env.adminKey); + if (config.status !== 200) + return { status: 'error', why: `reading the configuration of keyset ${id} answered HTTP ${config.status}` }; + return { status: 'read', config: config.json as KeysetConfig }; +} + +/** Features a role needs, as named in docs-snippets/README.md. Returns one sentence per missing feature. */ +export function missingFeatures(role: Role, c: KeysetConfig): string[] { + const out: string[] = []; + const on = (value: boolean | undefined, text: string): void => { + if (value !== true) out.push(`${text} is off on the ${role} keyset`); + }; + if (role === 'open') { + on(c.presence?.enabled, 'Presence'); + const wildcard = c.presence?.rules?.some((r) => r.pattern === '*' && r.tracking?.enabled === true); + on(wildcard, 'Presence tracking on all channels (rule pattern *)'); + on(c.messagePersistence?.enabled, 'Message Persistence'); + on(c.messagePersistence?.deleteFromHistory, 'Message Persistence Delete-From-History'); + on(c.appContext?.enabled, 'App Context'); + on(c.appContext?.userMetadataEvents, 'App Context user metadata events'); + on(c.appContext?.channelMetadataEvents, 'App Context channel metadata events'); + on(c.appContext?.membershipEvents, 'App Context membership events'); + if (c.appContext?.referentialIntegrity === true) + out.push('App Context referential integrity is on on the open keyset (it must be off)'); + if (c.appContext?.disallowGetAllUserMetadata === true || c.appContext?.disallowGetAllChannelMetadata === true) + out.push('App Context "disallow get all" is on on the open keyset (it must be off)'); + on(c.files?.enabled, 'Files'); + on(c.streamController?.enabled, 'Stream Controller'); + on(c.streamController?.wildcardSubscribe, 'Stream Controller wildcard subscribe'); + if (c.accessManager?.enabled === true) out.push('Access Manager is on on the open keyset (it must be off)'); + } else { + on(c.accessManager?.enabled, 'Access Manager'); + if (role === 'pam') on(c.accessManager?.tokenRevoke, 'Access Manager token revocation'); + on(c.messagePersistence?.enabled, 'Message Persistence'); + on(c.presence?.enabled, 'Presence'); + if (role === 'datasync') on(c.dataSync?.enabled, 'DataSync'); + } + return out; +} diff --git a/docs-snippets/harness/lint-markers.ts b/docs-snippets/harness/lint-markers.ts new file mode 100644 index 000000000..73498bf22 --- /dev/null +++ b/docs-snippets/harness/lint-markers.ts @@ -0,0 +1,331 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { isPlaceholder, isRegisteredName, PLACEHOLDER_LOOKALIKE } from '../fixtures/names.js'; +import { isInsideRegion, isVisibleLine, scanMarkers, type SnippetRegion } from './markers.js'; + +/** + * Marker lint. Snippets let errors throw, so `try`, + * `catch` and `console.error` inside a snippet are errors too. + * + * Rules: + * 1. id matches ^[A-Za-z0-9_-]+$ and is unique across the suite + * 2. no nested start marker, every snippet has snippet.end + * 3. snippet.hide has a snippet.show, at most 3 lines per hidden block, and hidden lines only hold + * in-scope asserts or live configuration (they must hold `world.` or `assert(` as code, comments do not count, + * except one `@ts-expect-error` line that is listed in the output) + * 4. a snippet sits inside a step() callback, except import-only snippets at module top level + * 5. a step id is one of the snippet ids it contains, `snippets` equals the markers inside, + * check() and stub() contain none + * 6. resource-like string literals inside a snippet are in fixtures/names.ts, placeholder-like literals are + * registered placeholders + * 7. no try, catch or console.error inside a snippet (except the ids in ERROR_HANDLING_SNIPPETS) + */ + +// Role: the static gate. It runs without keys or network (step 2 of `npm run test:docs-snippets:dry` and a gate before every +// live run) and guarantees the invariants the docs and the stage rewrite depend on: a snippet is a clean, embeddable +// region inside a step, with registered literals. +// +// A slash is allowed because the docs embed `simpleNotificationPayloadFCMandAPNSH/2`. The docs extractor +// matches the requested id literally, so it works there. +const ID = /^[A-Za-z0-9_/-]+$/; +const MAX_HIDDEN_LINES = 3; +/** + * Snippets whose whole purpose is to show try/catch and console.error (the publish error handling sample). + * Rule 7 does not apply to these ids. Keep this list short: every other snippet lets errors throw. + */ +const ERROR_HANDLING_SNIPPETS = new Set([ + 'publishHandleErrors', + // The pages show these calls inside try and catch. A hidden world.keep() at the end of the try hands the result to the + // step, and world.take() fails the step when the catch ran (harness/world.ts), so a swallowed error cannot pass. + 'grantTokenSpacesBasicUsage', 'grantTokenSpacesVariousResources', 'grantTokenSpacesRegEx', 'grantTokenSpacesRegExAndResources', + 'historyBasicUsage', 'historyOldestThree', 'historyNewerThanTimetoken', 'historyUntilTimetoken', 'historyIncludeMeta', + 'quickstartNodePublish', +]); +/** Directory of standalone quickstart snippet sources, see the end of lintApp. */ +const STANDALONE_DIR = 'quickstarts'; +const SCAN_DIRS = ['api-reference', 'shared', 'getting-started', 'migration-guides']; +const RESOURCE_KEYS = new Set(['channel', 'channelGroup', 'uuid', 'userId', 'id', 'device', 'channelId', 'spaceId', 'entityAId', 'entityBId']); +const RESOURCE_ARRAY_KEYS = new Set(['channels', 'channelGroups', 'uuids', 'userIds']); +const RESOURCE_CALLS = new Set([ + 'channel', + 'channelGroup', + 'channelMetadata', + 'userMetadata', + 'setUserId', + 'dataSyncUser', + 'dataSyncChannel', + 'dataSyncEntity', + 'dataSyncRelationship', + 'dataSyncMembership', +]); + +/** One lint finding. `line` is 1-based in `file` (relative to the suite directory). */ +export interface LintIssue { + file: string; + line: number; + message: string; +} + +/** `snippets` lists every region found (its step, or `importOnly` for module-level imports), the dry run compares it with the registry. */ +export interface LintResult { + errors: LintIssue[]; + files: number; + snippets: Array<{ id: string; file: string; step?: string; importOnly: boolean }>; + steps: number; + /** Hidden type suppressions that the rule allows, listed so they stay visible. */ + suppressions: string[]; +} + +// A `step()`, `check()` or `stub()` call found in the AST, with the line span of its callback. Snippet regions are matched to +// the call whose span contains them. +interface StepCall { + kind: 'step' | 'check' | 'stub'; + id: string; + startLine: number; + endLine: number; + declared?: string[]; +} + +/** Scans every page directory and the standalone quickstart sources. Pure file reads, safe to call from the dry run and the CLI. */ +export function lintApp(appDir: string): LintResult { + const errors: LintIssue[] = []; + const snippets: LintResult['snippets'] = []; + const seen = new Map(); + const suppressions: string[] = []; + let files = 0; + let steps = 0; + + for (const dir of SCAN_DIRS) { + const root = path.join(appDir, dir); + if (!fs.existsSync(root)) continue; + for (const file of walk(root).filter((f) => /\.tsx?$/.test(f))) { + files += 1; + const rel = path.relative(appDir, file); + const text = fs.readFileSync(file, 'utf8'); + const add = (line: number, message: string): void => { + errors.push({ file: rel, line, message }); + }; + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const lineOf = (pos: number): number => source.getLineAndCharacterOfPosition(pos).line + 1; + const { regions, problems } = scanMarkers(text); + problems.forEach((p) => add(p.line, p.message)); + + // Collect step(), check() and stub() calls. The callback is the last function argument, so both the `(id, fn)` and + // `(id, options, fn)` overloads work. Only string-literal ids count: a computed id could not be matched to markers. + const calls: StepCall[] = []; + const collect = (node: ts.Node): void => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const name = node.expression.text; + if ((name === 'step' || name === 'check' || name === 'stub') && node.arguments.length > 0) { + const first = node.arguments[0]; + const fn = [...node.arguments].reverse().find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a)); + if (ts.isStringLiteral(first)) { + let declared: string[] | undefined; + const options = node.arguments[1]; + if (options && ts.isObjectLiteralExpression(options)) { + for (const prop of options.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'snippets' && ts.isArrayLiteralExpression(prop.initializer)) { + declared = prop.initializer.elements.filter(ts.isStringLiteral).map((e) => e.text); + } + } + } + calls.push({ + kind: name, + id: first.text, + startLine: fn ? lineOf(fn.getStart(source)) : lineOf(node.getStart(source)), + endLine: fn ? lineOf(fn.end) : lineOf(node.end), + declared, + }); + } + } + } + ts.forEachChild(node, collect); + }; + collect(source); + steps += calls.filter((c) => c.kind === 'step').length; + + // An import-only snippet (`importPubNub`) is allowed at module level because an import cannot sit inside a function. + const topLevelOnlyImports = (region: SnippetRegion): boolean => { + const inside = source.statements.filter((s) => { + const l = lineOf(s.getStart(source)); + return isInsideRegion(region, l); + }); + return inside.length > 0 && inside.every(ts.isImportDeclaration); + }; + + const containing = new Map(); + for (const region of regions) { + // Rule 1. + if (!ID.test(region.id)) add(region.startLine, `snippet id "${region.id}" does not match ${ID}`); + const previous = seen.get(region.id); + if (previous) add(region.startLine, `duplicate snippet id "${region.id}" (also in ${previous})`); + else seen.set(region.id, `${rel}:${region.startLine}`); + + // Rule 3. + for (const block of region.hidden) { + if (block.showLine === undefined) continue; + const lines = block.showLine - block.hideLine - 1; + if (lines > MAX_HIDDEN_LINES) { + add(block.hideLine, `hidden block in "${region.id}" has ${lines} lines, maximum is ${MAX_HIDDEN_LINES}`); + } + const body = text.split('\n').slice(block.hideLine, block.showLine - 1).join('\n'); + // Comment text does not count: a comment that merely contains the word "assert" used to pass this rule. + const code = stripComments(body); + if (isTypeSuppression(body)) { + // The one documented exception: a single `@ts-expect-error` line for a property the shipped types lack. + // `@ts-expect-error` (not `@ts-ignore`) stops compiling when the SDK types are fixed, which removes the hole. + suppressions.push(`${rel}:${block.hideLine} (${region.id})`); + } else if (!/\bworld\.|\bassert\s*[.(]/.test(code)) { + add(block.hideLine, `hidden block in "${region.id}" must hold an in-scope assert or live configuration as code (world.* or assert(...)), comments do not count`); + } + } + + // Rule 4. + const owner = calls.find((c) => region.startLine >= c.startLine && region.endLine <= c.endLine); + let importOnly = false; + if (!owner) { + if (topLevelOnlyImports(region)) importOnly = true; + else add(region.startLine, `snippet "${region.id}" is not inside a step() callback (only import-only snippets may sit at module level)`); + } else { + const list = containing.get(owner) ?? []; + list.push(region.id); + containing.set(owner, list); + } + snippets.push({ id: region.id, file: rel, step: owner?.id, importOnly }); + + // Rules 6 and 7 walk the AST inside the visible region. Hidden lines are excluded for rule 6, because they hold + // harness configuration and not docs text. + const visit = (node: ts.Node): void => { + const startLine = lineOf(node.getStart(source)); + const inRegion = isInsideRegion(region, startLine); + if (inRegion && !ERROR_HANDLING_SNIPPETS.has(region.id) && ts.isTryStatement(node)) add(startLine, `try/catch inside snippet "${region.id}" (snippets let errors throw)`); + if ( + inRegion && + !ERROR_HANDLING_SNIPPETS.has(region.id) && + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.getText(source) === 'console' && + node.expression.name.text === 'error' + ) { + add(startLine, `console.error inside snippet "${region.id}" (snippets let errors throw)`); + } + if (ts.isStringLiteral(node) && isVisibleLine(region, startLine)) { + const value = node.text; + if (PLACEHOLDER_LOOKALIKE.test(value) && !isPlaceholder(value)) { + add(startLine, `placeholder-like literal "${value}" in "${region.id}" is not in fixtures/names.ts placeholders`); + } else if (isResourcePosition(node) && !isRegisteredName(value) && !isPlaceholder(value)) { + add(startLine, `resource-like literal "${value}" in "${region.id}" is not in fixtures/names.ts`); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + } + + // Rule 5. + for (const call of calls) { + const ids = containing.get(call) ?? []; + if (call.kind === 'step') { + if (ids.length === 0) add(call.startLine, `step "${call.id}" contains no snippet (use check() for harness-only steps)`); + else { + if (!ids.includes(call.id)) add(call.startLine, `step name "${call.id}" is not one of its snippet ids [${ids.join(', ')}]`); + if (ids[0] !== call.id && ids.includes(call.id)) add(call.startLine, `step "${call.id}" must be the first snippet id inside (found ${ids[0]} first)`); + const declared = call.declared ?? [call.id]; + if (declared.join('|') !== ids.join('|')) { + add(call.startLine, `step "${call.id}" declares snippets [${declared.join(', ')}] but contains [${ids.join(', ')}]`); + } + } + } else if (ids.length > 0) { + add(call.startLine, `${call.kind}() "${call.id}" contains snippet markers [${ids.join(', ')}], use step()`); + } + } + } + } + // Standalone sources (quickstarts/*.ts, *.tsx, *.jsx). They are the exact text of the Web, React and React Native + // quickstart pages and of the React, browser-only and React Native samples of the API reference. This repository cannot + // run them (no browser, no React, no React Native), so no step() callback runs them. Rules 1 and 2 apply (id shape, + // uniqueness, no nesting, snippet.end), hidden lines are not allowed except one `@ts-expect-error` line, and the + // step and literal rules (3 to 7) do not apply: the pages teach try/catch around publish and use their own channel + // literals. + const standalone = path.join(appDir, STANDALONE_DIR); + if (fs.existsSync(standalone)) { + for (const name of fs.readdirSync(standalone).filter((f) => /\.(tsx?|jsx)$/.test(f))) { + files += 1; + const rel = path.join(STANDALONE_DIR, name); + const source = fs.readFileSync(path.join(standalone, name), 'utf8'); + const { regions, problems } = scanMarkers(source); + problems.forEach((p) => errors.push({ file: rel, line: p.line, message: p.message })); + for (const region of regions) { + if (!ID.test(region.id)) errors.push({ file: rel, line: region.startLine, message: `snippet id "${region.id}" does not match ${ID}` }); + const previous = seen.get(region.id); + if (previous) errors.push({ file: rel, line: region.startLine, message: `duplicate snippet id "${region.id}" (also in ${previous})` }); + else seen.set(region.id, `${rel}:${region.startLine}`); + // The one exception, as in rule 3: a hidden block that is a single `@ts-expect-error` line for an SDK typing bug. + for (const block of region.hidden) { + const body = block.showLine === undefined ? '' : source.split('\n').slice(block.hideLine, block.showLine - 1).join('\n'); + if (isTypeSuppression(body)) suppressions.push(`${rel}:${block.hideLine} (${region.id})`); + else errors.push({ file: rel, line: block.hideLine, message: `snippet "${region.id}" hides lines, standalone snippets must show exactly what the page shows (only a single @ts-expect-error line may be hidden)` }); + } + snippets.push({ id: region.id, file: rel, importOnly: false }); + } + } + } + return { errors, files, snippets, steps, suppressions }; +} + +/** + * Removes `//` line comments and block comments, so rule 3 sees only code. The negative class before `//` keeps `https://` inside + * a string from being cut. A heuristic, adequate for hidden blocks of at most three lines. + */ +export function stripComments(body: string): string { + return body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1'); +} + +/** A hidden block that is exactly one `// @ts-expect-error` comment line. */ +export function isTypeSuppression(body: string): boolean { + const lines = body.split('\n').filter((l) => l.trim() !== ''); + return lines.length === 1 && /^\s*\/\/\s*@ts-expect-error\b/.test(lines[0]); +} + +function isResourcePosition(node: ts.StringLiteral): boolean { + const parent = node.parent; + if (ts.isPropertyAssignment(parent) && parent.initializer === node) { + return ts.isIdentifier(parent.name) && RESOURCE_KEYS.has(parent.name.text); + } + if (ts.isArrayLiteralExpression(parent)) { + const holder = parent.parent; + return ts.isPropertyAssignment(holder) && ts.isIdentifier(holder.name) && RESOURCE_ARRAY_KEYS.has(holder.name.text); + } + if (ts.isCallExpression(parent) && parent.arguments[0] === node && ts.isPropertyAccessExpression(parent.expression)) { + return RESOURCE_CALLS.has(parent.expression.name.text); + } + return false; +} + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? walk(full) : [full]; + }); +} + +export function printLint(result: LintResult): void { + const ids = result.snippets.length; + const importOnly = result.snippets.filter((s) => s.importOnly).length; + if (result.errors.length === 0) { + console.log(`marker lint ok. ${result.files} files, ${result.steps} steps, ${ids} snippets (${importOnly} import-only).`); + for (const s of result.suppressions) console.log(` allowed hidden @ts-expect-error: ${s}`); + return; + } + for (const issue of result.errors) console.error(`${issue.file}:${issue.line} ${issue.message}`); + console.error(`marker lint failed with ${result.errors.length} problem(s) in ${result.files} files.`); +} + +const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (invokedDirectly) { + const result = lintApp(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')); + printLint(result); + process.exit(result.errors.length === 0 ? 0 : 1); +} diff --git a/docs-snippets/harness/manifest.ts b/docs-snippets/harness/manifest.ts new file mode 100644 index 000000000..a332d69f4 --- /dev/null +++ b/docs-snippets/harness/manifest.ts @@ -0,0 +1,156 @@ +/** + * Coverage manifest: `docs-snippets/coverage.json`, one entry per snippet id, generated from the marker lint and the step + * registry. It says for every id how the suite covers it: + * + * live a step runs it against the service and asserts the effect + * compile-only type checked only: import-only snippets, and the browser sources of tsconfig.browser.json + * stub not run and not compiled here: React and React Native sources, which cannot run in this repository (the SDK has no + * React or React Native dependencies), and the documented `stub()` steps of pages that need a browser or React Native + * + * `npm run docs-snippets:manifest -- --check` fails on a marker id without a step, a step id without a marker, a duplicate id + * or a stale coverage.json. It also reports, without failing, docs ids missing from the manifest and manifest ids that no + * docs page uses. Docs ids come from `` tags under docs/sdks/javascript of + * the docs checkout in DOCS_SNIPPETS_DOCS_DIR (default: a `documentation` checkout next to this repository). + */ +import fs from 'node:fs'; +import path from 'node:path'; +import type { LintResult } from './lint-markers.js'; +import type { Registry } from './registry.js'; + +export interface ManifestEntry { + file: string | null; + step: string | null; + role: string | null; + phase: string | null; + mode: 'live' | 'compile-only' | 'stub'; + reason: string | null; +} + +export interface Manifest { + $comment: string; + snippets: Record; +} + +/** Sources type checked by tsconfig.browser.json. Keep in sync with its `include`. */ +const BROWSER_CHECKED = [ + 'quickstarts/quickstart-web.ts', + 'quickstarts/browser-download-file.ts', + 'quickstarts/browser-shared-worker.ts', +]; + +export function buildManifest(lint: LintResult, reg: Registry): { manifest: Manifest; problems: string[] } { + const problems: string[] = []; + const entries: Record = {}; + const seen = new Map(); + for (const snippet of lint.snippets) { + const previous = seen.get(snippet.id); + if (previous) problems.push(`duplicate snippet id ${snippet.id} (${previous} and ${snippet.file})`); + seen.set(snippet.id, snippet.file); + if (snippet.step) { + const def = reg.get(snippet.step); + if (!def) { + problems.push(`marker ${snippet.id} sits in step ${snippet.step}, which the registry does not know`); + continue; + } + entries[snippet.id] = { + file: snippet.file, + step: def.id, + role: def.keyset, + phase: def.phase, + mode: def.skipIf ? 'compile-only' : 'live', + reason: def.skipIf ?? null, + }; + } else if (snippet.importOnly) { + entries[snippet.id] = { + file: snippet.file, + step: null, + role: null, + phase: null, + mode: 'compile-only', + reason: 'import-only snippet, type checked by tsconfig.live.json', + }; + } else if (snippet.file.startsWith('quickstarts/')) { + const browser = BROWSER_CHECKED.includes(snippet.file); + entries[snippet.id] = { + file: snippet.file, + step: null, + role: null, + phase: null, + mode: browser ? 'compile-only' : 'stub', + reason: browser + ? 'browser-only, type checked by tsconfig.browser.json' + : 'react or react-native source, kept for the docs URLs, cannot be run in this repository', + }; + } else { + problems.push(`marker ${snippet.id} in ${snippet.file} belongs to no step`); + } + } + for (const def of reg.ordered()) { + if (def.kind === 'stub') { + entries[def.id] = { + file: null, + step: def.id, + role: def.keyset, + phase: def.phase, + mode: 'stub', + reason: def.skipIf ?? null, + }; + continue; + } + for (const id of def.snippets) { + if (!seen.has(id)) problems.push(`step ${def.id} declares snippet ${id}, but no marker defines it`); + else if (entries[id]?.step !== def.id) + problems.push( + `snippet ${id} is declared by step ${def.id} but its marker sits in ${entries[id]?.step ?? 'no step'}`, + ); + } + } + const sorted = Object.fromEntries( + Object.keys(entries) + .sort() + .map((id) => [id, entries[id]]), + ); + return { + manifest: { + $comment: + 'Generated by npm run docs-snippets:manifest. Do not edit. One entry per snippet id: how the live suite covers it.', + snippets: sorted, + }, + problems, + }; +} + +export function serialize(manifest: Manifest): string { + return `${JSON.stringify(manifest, null, 2)}\n`; +} + +/** Snippet ids the JavaScript docs pages use, from their EmbeddedCode tags. Undefined when the dir is absent. */ +export function docsIds(docsDir: string): { ids: Map; files: number } | undefined { + const root = path.join(docsDir, 'docs', 'sdks', 'javascript'); + if (!fs.existsSync(root)) return undefined; + const ids = new Map(); + let files = 0; + const add = (id: string, where: string): void => { + ids.set(id, [...(ids.get(id) ?? []), where]); + }; + const walk = (dir: string): string[] => + fs + .readdirSync(dir, { withFileTypes: true }) + .flatMap((e) => + e.isDirectory() ? walk(path.join(dir, e.name)) : /\.mdx?$/.test(e.name) ? [path.join(dir, e.name)] : [], + ); + for (const file of walk(root)) { + files += 1; + const rel = path.relative(docsDir, file); + const text = fs.readFileSync(file, 'utf8'); + // Only EmbeddedCode tags with a JavaScript SDK repository URL count. + for (const tag of text.matchAll(//g)) { + if (!/pubnub\/javascript\//.test(tag[0])) continue; + const list = /snippets=\{\[([^\]]*)\]\}/.exec(tag[0]); + const single = /snippet=\{?['"]([^'"]+)['"]\}?/.exec(tag[0]); + const found = list ? [...list[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]) : single ? [single[1]] : []; + for (const id of found) add(id, rel); + } + } + return { ids, files }; +} diff --git a/docs-snippets/harness/markers.ts b/docs-snippets/harness/markers.ts new file mode 100644 index 000000000..bcc165644 --- /dev/null +++ b/docs-snippets/harness/markers.ts @@ -0,0 +1,117 @@ +/** + * Snippet marker scanner. It mirrors the extractor the docs use (`src/helper/codeSnippetFetcher.ts` in the + * documentation repo): `// snippet.` starts a snippet, `// snippet.end` ends it, `// snippet.hide` and + * `// snippet.show` toggle visibility, and any other start marker silently ends the snippet before it. + * The scanner reports that last case as nesting. + */ + +// Matches the same marker syntax as the docs extractor. Ids may contain `/` (one docs id does). `LOOSE_MARKER` catches near +// misses such as `//snippet.foo bar` or `// end.snippet`, which the extractor would silently ignore. +const MARKER = /^\s*\/\/\s*snippet\.([A-Za-z0-9_/-]+)\s*$/; +const LOOSE_MARKER = /^\s*\/\/\s*(?:snippet\.|end\.snippet)/; + +export interface HiddenBlock { + /** 1-based line of the `snippet.hide` marker. */ + hideLine: number; + /** 1-based line of the `snippet.show` marker, or undefined when it is missing. */ + showLine?: number; +} + +export interface SnippetRegion { + id: string; + /** 1-based line of the start marker. */ + startLine: number; + /** 1-based line of the end marker, or the line that implicitly closed it. */ + endLine: number; + explicitEnd: boolean; + hidden: HiddenBlock[]; +} + +export interface MarkerProblem { + line: number; + message: string; +} + +export interface ScanResult { + regions: SnippetRegion[]; + problems: MarkerProblem[]; +} + +/** + * One pass over a file. Returns every closed region plus problems (nesting, missing end, hide without show). Line numbers + * are 1-based. Shared by the lint, the stage rewrite and the compare tool, so all three agree on what a snippet is. + */ +export function scanMarkers(text: string): ScanResult { + const lines = text.split('\n'); + const regions: SnippetRegion[] = []; + const problems: MarkerProblem[] = []; + let open: SnippetRegion | undefined; + let openHide: HiddenBlock | undefined; + + const close = (line: number, explicit: boolean): void => { + if (!open) return; + open.endLine = line; + open.explicitEnd = explicit; + if (openHide) { + problems.push({ line: openHide.hideLine, message: `snippet.hide in "${open.id}" has no snippet.show` }); + openHide = undefined; + } + regions.push(open); + open = undefined; + }; + + lines.forEach((raw, index) => { + const line = index + 1; + const match = raw.match(MARKER); + if (!match) { + if (LOOSE_MARKER.test(raw)) { + problems.push({ line, message: `marker-like comment is not a valid marker: ${raw.trim()}` }); + } + return; + } + const word = match[1]; + if (word === 'end') { + if (!open) problems.push({ line, message: 'snippet.end without an open snippet' }); + else close(line, true); + } else if (word === 'hide') { + if (!open) problems.push({ line, message: 'snippet.hide outside a snippet' }); + else if (openHide) problems.push({ line, message: 'snippet.hide inside a hidden block' }); + else { + openHide = { hideLine: line }; + open.hidden.push(openHide); + } + } else if (word === 'show') { + if (!open || !openHide) problems.push({ line, message: 'snippet.show without a matching snippet.hide' }); + else { + openHide.showLine = line; + openHide = undefined; + } + } else { + if (open) { + problems.push({ + line, + message: `snippet "${word}" starts inside snippet "${open.id}" (nesting silently truncates the outer snippet)`, + }); + close(line, false); + } + open = { id: word, startLine: line, endLine: line, explicitEnd: false, hidden: [] }; + } + }); + + if (open) { + const dangling: SnippetRegion = open; + problems.push({ line: dangling.startLine, message: `snippet "${dangling.id}" has no snippet.end` }); + close(lines.length + 1, false); + } + return { regions, problems }; +} + +/** True when `line` (1-based) is a visible line inside `region`, not a marker and not hidden. */ +export function isVisibleLine(region: SnippetRegion, line: number): boolean { + if (line <= region.startLine || line >= region.endLine) return false; + return !region.hidden.some((h) => line >= h.hideLine && line <= (h.showLine ?? region.endLine)); +} + +export function isInsideRegion(region: SnippetRegion, line: number): boolean { + return line > region.startLine && line < region.endLine; +} diff --git a/docs-snippets/harness/mocha-entry.ts b/docs-snippets/harness/mocha-entry.ts new file mode 100644 index 000000000..4cb335d50 --- /dev/null +++ b/docs-snippets/harness/mocha-entry.ts @@ -0,0 +1,352 @@ +/** + * The Mocha spec file: where the registry becomes tests. It builds the `World`, registers every page, selects the steps of + * this run and creates one `it()` per step in phase order. It is loaded from the staged copy + * `docs-snippets/.run//harness/`, never from the source tree. It is not named `*.test.ts`, so the SDK's root + * `.mocharc.json` never picks it up, and it never loads `test/setup-why.ts` (that file restores nock). + * + * Per step it: sets the role, decides skip (registry.skipReason), runs the body, waits one tick for stray promise + * rejections (guard.ts), runs the honesty checks, records the result for later `needs`, and tags the Mocha test with + * `pnMeta`, `pnReason` and `pnNote` so reporter.cjs can write the JSON. + * + * Honesty checks after a passing body (`afterStep`): every snippet id of the step reached its `snippet.end` line (the + * staged copy calls `__snippetRan('')` there, see stage.ts), and the step recorded at least one assertion. They run + * inside the test and not in a Mocha `afterEach` hook on purpose: a failing hook makes Mocha abort every later test of the + * suite, a failing check here fails only this step. + * + * Selection: `DOCS_SNIPPETS_SHARD` (a role), `DOCS_SNIPPETS_GREP` (set by the CLI from `--grep`) and `DOCS_SNIPPETS_ONLY` + * (exact ids) choose steps. A chosen step's `needs` that the filter left out run on demand before it, with their own role's + * keys, and are reported as prerequisites, not as passed tests. + * + * Root hooks: `before` closes any leftover application-level grant on the secured keysets this run uses and proves that a + * stranger is refused. `after` runs the janitor, proves the same again, and fails the run on residue or a critical cleanup + * error. + */ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { drainGuard, describeError, explainError, installGuard } from './guard.js'; +import { loadEnv, securedKeysets, type Role } from './env.js'; +import { registry, rolesOf, selectSteps, stepTitle, type StepDef } from './registry.js'; +import { World } from './world.js'; +import { runJanitor } from './janitor.js'; +import { closeAndVerifyApplicationGrant, revokeApplicationGrantOnKeyset } from './revoke.js'; +import { recorder } from './recorder.js'; +import assert from './assert.js'; +import { installQuiet, mute, unmute } from './quiet.js'; +import { installShutdown } from './signals.js'; +import { installRedaction } from './redact.cjs'; +import { SkipStep } from './step.js'; +import { registerAll } from '../run.js'; + +/** + * DOCS_SNIPPETS_DRY=1 swaps every step body for a no-op so the Mocha binding, the reporter and the skip logic can be + * exercised without a network. DOCS_SNIPPETS_DRY_FAIL names one step that throws. Used by `cli.ts dry` only. + */ +const dry = process.env.DOCS_SNIPPETS_DRY === '1'; +const dryFail = process.env.DOCS_SNIPPETS_DRY_FAIL; + +installRedaction(); +const env = loadEnv(); +const world = new World(env, env.runId); +installGuard(); +// SDK client logs printed by snippets are collected per step (quiet.ts). A user who set a log level or +// DOCS_SNIPPETS_VERBOSE=1 wants to see them, so nothing is muted then. +installQuiet({ + enabled: process.env.DOCS_SNIPPETS_LOG_LEVEL === undefined && process.env.DOCS_SNIPPETS_VERBOSE !== '1', +}); +registry.reset(); +registry.availability = dry + ? { open: true, pam: true, datasync: true } + : { open: Boolean(env.open), pam: Boolean(env.pam), datasync: Boolean(env.datasync) }; +registerAll(world); +const wiring = registry.validate(); +if (wiring.length > 0) throw new Error(`step wiring is broken:\n${wiring.join('\n')}`); + +const only = process.env.DOCS_SNIPPETS_ONLY?.split(',') + .map((id) => id.trim()) + .filter(Boolean); +const shard = env.shard; +const selection = selectSteps(registry, { shard, grep: process.env.DOCS_SNIPPETS_GREP || undefined, only }); +/** Roles that run in this process (selected steps and their prerequisites). Only their keysets are guarded and cleaned. */ +const usedRoles: Set = rolesOf(registry, selection); + +const startedAt = Date.now(); + +// Published for the reporter (same process): the full plan and the selection, so the report can list prerequisites and +// steps that a filter left out. +(globalThis as { __pnDocsPlan?: unknown }).__pnDocsPlan = registry.ordered().map((def) => ({ + id: def.id, + page: def.page, + phase: def.phase, + kind: def.kind, + snippets: def.snippets, + keyset: def.keyset, + proves: def.proves, + selected: selection.run.has(def.id) && !selection.prerequisites.has(def.id), + prerequisite: selection.prerequisites.has(def.id), +})); +(globalThis as { __pnDocsSelection?: unknown }).__pnDocsSelection = { + shard: shard ?? null, + grep: process.env.DOCS_SNIPPETS_GREP ?? null, + only: only ?? null, + mutate: process.env.DOCS_SNIPPETS_MUTATE ?? null, + roles: [...usedRoles], +}; + +// Snippet ids that reached their `snippet.end` line in the running step (the staged copy calls this, see stage.ts). +const ran = new Set(); +(globalThis as { __snippetRan?: (id: string) => void }).__snippetRan = (id: string): void => { + ran.add(id); +}; +// Assertions recorded before the first snippet of the step started (fixtures, `watch()` waits). They do not count for the +// zero-assertion check, which asks whether the step asserted anything about the docs code. +let beforeSnippet: number | undefined; +(globalThis as { __snippetStart?: (id: string) => void }).__snippetStart = (): void => { + if (beforeSnippet === undefined) beforeSnippet = recorder.total; +}; + +/** + * Debug aid: DOCS_SNIPPETS_INJECT_FAILURE=[,] runs the step normally, then makes it throw an SDK-style 403. + * It exists to look at the failure part of the report. The CLI refuses it in CI (policy.ts). + */ +const injected = new Set( + (process.env.DOCS_SNIPPETS_INJECT_FAILURE ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean), +); +function injectedError(id: string): Error { + const error = new Error('REST API request processing error, check status for details'); + error.name = 'PubNubError'; + (error as Error & { status?: unknown }).status = { + error: true, + category: 'PNAccessDeniedCategory', + operation: 'PNPublishOperation', + statusCode: 403, + errorData: { + message: `Forbidden (injected by DOCS_SNIPPETS_INJECT_FAILURE for step ${id})`, + status: 403, + service: 'Access Manager', + }, + }; + return error; +} + +/** Secured keysets this run touches, without duplicates. */ +const guarded = securedKeysets(env).filter( + (k) => usedRoles.has(k.role) || (env.sharedSecured && usedRoles.has('datasync')), +); + +/** Proves that a stranger (no auth key, no token) is refused on every guarded keyset. Used before and after the steps. */ +async function proveNoApplicationGrant(when: string): Promise { + for (const { role, keys } of guarded) { + const waited = await closeAndVerifyApplicationGrant(keys, `${when}-r${world.runId}`); + console.log( + `${env.sharedSecured ? 'pam and datasync' : role} keyset: a client without a token is refused (${when}${waited.attempts > 1 ? `, after ${Math.round(waited.ms / 1000)} s` : ''})`, + ); + } +} + +if (!dry) { + installShutdown( + // First and alone: close the application-level grant on every secured keyset this run uses. One quick call each, so it + // finishes before a CI cancel escalates to SIGKILL. + async () => { + // Stop collecting console output (quiet.ts): from here on every line of the cleanup must be visible. + unmute(); + // All keysets are tried, a failure on one never skips the others. The first failure is thrown afterwards. + const results = await Promise.allSettled( + guarded.map(async ({ role, keys }) => { + await revokeApplicationGrantOnKeyset(keys, `signal-r${world.runId}`, 1); + process.stderr.write(`${role} keyset: application-level grant revoked\n`); + }), + ); + const failed = results.find((r): r is PromiseRejectedResult => r.status === 'rejected'); + if (failed) throw failed.reason; + }, + async () => { + const report = await runJanitor(env, world.runId, new Date(startedAt), [...usedRoles]); + for (const line of report.log) console.log(`janitor: ${line}`); + await world.dispose(); + }, + ); + if (process.env.DOCS_SNIPPETS_PRINT_RESOLVE !== '0') { + // The suite tests this repository's build, never a package from npm. + console.log(`pubnub resolves to ${fs.realpathSync(fileURLToPath(import.meta.resolve('pubnub')))}`); + } +} + +/** Honesty checks of a passing step, see the header. */ +function afterStep(def: StepDef, assertions: number): void { + if (def.kind !== 'step' || dry) return; + const missing = def.snippets.filter((id) => !ran.has(id)); + if (missing.length > 0) { + throw new Error( + `honesty check: snippet ${missing.join(', ')} of step ${def.id} did not reach its snippet.end line, so the docs code did not run to the end`, + ); + } + if (assertions === 0) { + throw new Error( + `honesty check: step ${def.id} recorded zero assertions after its snippet started, so it proves nothing about what the docs page claims`, + ); + } +} + +describe('JavaScript SDK docs snippets', function () { + this.timeout(30_000); + + for (const def of registry.ordered()) { + if (!selection.run.has(def.id)) continue; + const prerequisite = selection.prerequisites.has(def.id); + const test = it(`${prerequisite ? '(prerequisite) ' : ''}${stepTitle(def)}`, async function () { + this.timeout(def.timeout); + world.useRole(def.keyset); + const reason = registry.skipReason(def); + if (reason) { + registry.record(def.id, { status: 'skipped', reason }); + (this.test as unknown as { pnReason?: string }).pnReason = reason; + this.skip(); + } + drainGuard(); + ran.clear(); + beforeSnippet = undefined; + const began = Date.now(); + const tagged = this.test as unknown as { + pnAssertions?: unknown; + pnDropped?: number; + pnSuppressed?: number; + pnOutput?: string[]; + }; + recorder.begin(); + mute(); + let collected: string[] = []; + let collectedTotal = 0; + let thrown: unknown; + let failedStep = false; + let assertionCount = 0; + try { + if (dry) { + await dryAssertions(def.id, def.id === dryFail); + if (injected.has(def.id)) throw injectedError(def.id); + } else { + await def.fn(); + if (injected.has(def.id)) throw injectedError(def.id); + } + // A snippet may leave a promise un-awaited. One macrotask later it has settled, and a rejection recorded by + // guard.ts fails this step instead of leaking into the next one. + await new Promise((resolve) => setImmediate(resolve)); + const stray = drainGuard(); + if (stray.length > 0) { + throw new Error( + `unhandled rejection during step ${def.id}: ${stray.map((s) => describeError(s.error)).join('; ')}`, + ); + } + } catch (error) { + thrown = error; + failedStep = true; + } finally { + const quietResult = unmute(); + collected = quietResult.lines; + collectedTotal = quietResult.total; + const afterStart = recorder.total - (beforeSnippet ?? 0); + const { assertions, dropped } = recorder.end(); + assertionCount = afterStart; + tagged.pnAssertions = assertions; + tagged.pnDropped = dropped; + tagged.pnSuppressed = collectedTotal; + if (failedStep && !(thrown instanceof SkipStep)) tagged.pnOutput = collected.slice(-60); + } + if (!failedStep) { + try { + afterStep(def, assertionCount); + } catch (error) { + thrown = error; + failedStep = true; + } + } + if (!failedStep) { + registry.record(def.id, { status: 'passed', durationMs: Date.now() - began }); + return; + } + const error = thrown; + if (error instanceof SkipStep) { + registry.record(def.id, { status: 'skipped', reason: error.reason }); + (this.test as unknown as { pnReason?: string; pnNote?: string }).pnReason = error.reason; + (this.test as unknown as { pnNote?: string }).pnNote = error.detail; + if (error.detail) console.log(`skipped ${def.id} (${error.reason}): ${error.detail}`); + this.skip(); + } + explainError(error); + registry.record(def.id, { status: 'failed', error: describeError(error), durationMs: Date.now() - began }); + if (collected.length > 0) + console.error( + `console output of ${def.id} before it failed (last ${Math.min(collected.length, 30)} of ${collectedTotal} lines):\n${collected.slice(-30).join('\n')}`, + ); + throw error; + }); + (test as unknown as { pnMeta: unknown }).pnMeta = { + id: def.id, + page: def.page, + phase: def.phase, + kind: def.kind, + snippets: def.snippets, + skipIf: def.skipIf, + keyset: def.keyset, + needs: def.needs, + proves: def.proves, + prerequisite, + }; + } + + // A crashed earlier run, or a grant of another job on a shared keyset, may leave a secured keyset open. Steps such as + // `publishHandleErrors` need the keyset to really deny a client without a token, so prove it before the first step. + before(async function () { + this.timeout(150_000); + if (dry) return; + await proveNoApplicationGrant('before'); + }); + + after(async function () { + this.timeout(240_000); + try { + if (!dry && (env.open || env.pam || env.datasync)) { + const report = await runJanitor(env, world.runId, new Date(startedAt), [...usedRoles]); + for (const line of report.log) console.log(`janitor: ${line}`); + if (report.critical.length > 0) { + throw new Error(`janitor could not make the keysets safe: ${report.critical.join('; ')}`); + } + // Residue = objects created during the run whose id has no run suffix, so a snippet wrote a literal that is missing + // from fixtures/names.ts and the next run would collide with it. + if (report.residue.length > 0) { + throw new Error( + `residue: ${report.residue.length} object(s) created during the run have no run suffix, so a snippet uses ` + + `a literal missing from fixtures/names.ts: ${report.residue.join(', ')}`, + ); + } + // The janitor revoked the application-level grant. Wait until a stranger is refused again, so the next job on a + // shared keyset starts closed. + await proveNoApplicationGrant('after'); + } + } finally { + mute(); + await world.dispose(); + await new Promise((resolve) => setImmediate(resolve)); + unmute(); + } + }); +}); + +/** + * Dry run only: makes a no-op step look like a real one for the report, so the dry run proves that assertions, failures and + * descriptions reach report/run.json without a network. `fail` ends in a failing assertion. + */ +async function dryAssertions(id: string, fail: boolean): Promise { + assert.equal(1 + 1, 2, `dry run assertion of ${id}`); + assert.ok(true, 'dry run: ok assertion'); + if (fail) + assert.equal( + 1, + 1, + 'dry run: label with a key pub-c-0123abcd-0123-4abc-8def-0123456789ab and token p0F2AkF0Gl' + 'A'.repeat(30), + ); + if (fail) assert.deepEqual(['actual'], ['expected'], 'dry run: simulated failing assertion'); +} diff --git a/docs-snippets/harness/policy.ts b/docs-snippets/harness/policy.ts new file mode 100644 index 000000000..0a4b62861 --- /dev/null +++ b/docs-snippets/harness/policy.ts @@ -0,0 +1,83 @@ +import type { Env } from './env.js'; + +/** + * Run policy: what turns a technically green Mocha run into a failed job. Pure functions, so the dry run tests every rule + * without keys. `cli.ts` calls `launchProblems` before the run and `outcomeProblems` after it. + * + * - Skip budget: steps skipped as `blocked-by:*` mean a root cause hid other steps. `DOCS_SNIPPETS_SKIP_BUDGET` caps them. + * In CI the default is 0. + * - In CI, every subset option is refused: `--grep`, `DOCS_SNIPPETS_ONLY`, `DOCS_SNIPPETS_MUTATE`, + * `DOCS_SNIPPETS_INJECT_FAILURE`. A subset run must never pass as the full run. A shard is not a subset: the workflow runs + * all three. + * - In CI, `trace` and `debug` log levels are refused because the SDK logs its configuration at those levels. + * - Any `missing-env` skip in CI fails. + * - `needs-feature:` is an allowed skip outside the budget, but `DOCS_SNIPPETS_REQUIRE_FEATURES=,...` makes the + * named ones fatal. + * - `DOCS_SNIPPETS_STRICT=1` makes every skip fatal. + */ + +export interface PolicyReport { + skips: Array<{ id: string; reason: string; note?: string }>; +} + +/** Options that select a subset of the plan or break a step on purpose. Each is refused in CI. */ +export const SUBSET_VARS = ['DOCS_SNIPPETS_ONLY', 'DOCS_SNIPPETS_MUTATE', 'DOCS_SNIPPETS_INJECT_FAILURE'] as const; + +export function launchProblems( + env: Env, + vars: Record = process.env, + args: { grep?: string } = {}, +): string[] { + const problems: string[] = []; + if (env.skipBudgetProblem) problems.push(env.skipBudgetProblem); + if (env.shardProblem) problems.push(env.shardProblem); + if (env.ci) { + if (args.grep) problems.push('--grep is set in CI. A subset run must not pass as the full run. Remove it.'); + for (const name of SUBSET_VARS) { + if (vars[name]) + problems.push( + `${name} is set in CI. It selects a subset or breaks a step on purpose, which must never pass as the full run. Unset it.`, + ); + } + if (env.logLevel === 'trace' || env.logLevel === 'debug') { + problems.push( + `DOCS_SNIPPETS_LOG_LEVEL=${env.logLevel} is refused in CI. The SDK logs its configuration at that level, which includes keys. Use info or lower.`, + ); + } + } + return problems; +} + +export function outcomeProblems(env: Env, report: PolicyReport): string[] { + const problems: string[] = []; + const missing = report.skips.filter((s) => s.reason.startsWith('missing-env:')); + if (env.ci && missing.length > 0) + problems.push(`${missing.length} step(s) were skipped for missing env in CI, which fails the run`); + const blocked = report.skips.filter((s) => s.reason.startsWith('blocked-by:')); + if (blocked.length > env.skipBudget) { + const roots = [...new Set(blocked.map((s) => s.reason.slice('blocked-by:'.length)))]; + problems.push( + `${blocked.length} step(s) were skipped as blocked-by, more than the budget of ${env.skipBudget} (DOCS_SNIPPETS_SKIP_BUDGET). Root causes: ${roots.join(', ')}`, + ); + } + for (const feature of env.requireFeatures) { + const hit = report.skips.filter((s) => s.reason === `needs-feature:${feature}`); + if (hit.length > 0) { + problems.push( + `${hit.length} step(s) were skipped as needs-feature:${feature} but DOCS_SNIPPETS_REQUIRE_FEATURES lists it: ${hit.map((s) => s.id).join(', ')}`, + ); + } + } + if (env.strict && report.skips.length > 0) { + const reasons = [...new Set(report.skips.map((s) => s.reason))]; + problems.push( + `${report.skips.length} step(s) were skipped and DOCS_SNIPPETS_STRICT=1 makes every skip fatal. Reasons: ${reasons.join(', ')}`, + ); + } + return problems; +} + +/** Every `needs-feature:*` skip, for the end-of-run summary. The tag is allowed, so it needs to be visible instead. */ +export function featureSkips(report: PolicyReport): Array<{ id: string; reason: string; note?: string }> { + return report.skips.filter((s) => s.reason.startsWith('needs-feature:')); +} diff --git a/docs-snippets/harness/preflight-errors.ts b/docs-snippets/harness/preflight-errors.ts new file mode 100644 index 000000000..575e1d369 --- /dev/null +++ b/docs-snippets/harness/preflight-errors.ts @@ -0,0 +1,75 @@ +/** + * Role: used by preflight.ts to produce the failure message, and by the dry run to test every cause without a network. + * + * Turns an SDK error from a preflight call into one named cause. Before this, every failure read "the keyset does not + * have ", which was wrong for a mistyped key or an offline laptop. + * + * Causes, in the order they are tested: + * - network: no HTTP status, or a network and timeout category. Nothing is wrong with the keyset. + * - bad-key: HTTP 400 or 401 whose service message names the key, or 401. Check the variable that holds the key. + * - feature-off: HTTP 403. The key is fine and the portal feature is off (or Access Manager needs a token). + * - bad-request: any other 400. Not a missing feature. Look at the service message. + * - unknown: anything else. + */ + +export type PreflightCause = 'network' | 'bad-key' | 'feature-off' | 'bad-request' | 'unknown'; + +interface SdkLikeError { + message?: string; + code?: string; + status?: { category?: string; statusCode?: number; errorData?: unknown; message?: string }; +} + +const NETWORK_CATEGORIES = new Set(['PNNetworkIssuesCategory', 'PNNetworkDownCategory', 'PNTimeoutCategory', 'PNConnectionErrorCategory']); +const NETWORK_CODES = new Set(['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'EHOSTUNREACH']); + +function serviceText(error: SdkLikeError): string { + const data = error.status?.errorData; + let text = ''; + try { + text = typeof data === 'string' ? data : JSON.stringify(data ?? ''); + } catch { + text = String(data); + } + return `${text} ${error.status?.message ?? ''} ${error.message ?? ''}`; +} + +export function classifyPreflightError(error: unknown): { cause: PreflightCause; detail: string } { + const e = (error ?? {}) as SdkLikeError; + const code = e.status?.statusCode; + const category = e.status?.category; + const text = serviceText(e); + const detail = [category, code, e.message].filter(Boolean).join(' '); + if ((category && NETWORK_CATEGORIES.has(category)) || (e.code && NETWORK_CODES.has(e.code)) || (code === undefined && /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network|fetch failed|timed out/i.test(text))) { + return { cause: 'network', detail }; + } + if (code === 401 || ((code === 400 || code === 403) && /invalid (subscribe |publish )?key|invalid subscribe|invalid publish|key.*not found|no such key|invalid.*subscribe.?key/i.test(text))) { + return { cause: 'bad-key', detail }; + } + if (code === 403) return { cause: 'feature-off', detail }; + if (code === 400) return { cause: 'bad-request', detail }; + return { cause: 'unknown', detail }; +} + +/** The message a failed preflight throws. `feature` and `setting` are used only when the cause is a missing feature. */ +export function preflightMessage( + error: unknown, + keyset: 'open' | 'pam' | 'datasync', + feature: string, + setting: string, +): string { + const { cause, detail } = classifyPreflightError(error); + const vars = keyset === 'open' ? 'PUBLISH_KEY and SUBSCRIBE_KEY' : keyset === 'pam' ? 'PAM_PUBLISH_KEY, PAM_SUBSCRIBE_KEY and PAM_SECRET_KEY' : 'DS_PUBLISH_KEY, DS_SUBSCRIBE_KEY and DS_SECRET_KEY'; + switch (cause) { + case 'network': + return `Network problem, not a keyset problem: PubNub could not be reached while checking ${feature} on the ${keyset} keyset. Check connectivity and proxy settings, then rerun. ${detail}`; + case 'bad-key': + return `The ${keyset} keyset keys were rejected, so the keys are wrong or belong to another keyset. Check ${vars}. ${detail}`; + case 'feature-off': + return `The ${keyset} keyset does not have ${feature}. The keys work. Enable "${setting}" for this keyset in the Admin Portal (see docs-snippets/README.md, "Keysets"). ${detail}`; + case 'bad-request': + return `The service answered 400 while checking ${feature} on the ${keyset} keyset. That is not a missing feature, so look at the service message first. ${detail}`; + default: + return `Could not verify ${feature} on the ${keyset} keyset (unclassified error). ${detail}`; + } +} diff --git a/docs-snippets/harness/preflight-summary.ts b/docs-snippets/harness/preflight-summary.ts new file mode 100644 index 000000000..2f28eb5a7 --- /dev/null +++ b/docs-snippets/harness/preflight-summary.ts @@ -0,0 +1,190 @@ +import { explainError } from './guard.js'; +import assert from './assert.js'; +import { readFeatures, missingFeatures } from './keyset-features.js'; +import { registry } from './registry.js'; +import { page, check } from './step.js'; +import { eventually, watch } from './wait.js'; +import type { Role } from './env.js'; + +/** + * One message per keyset that names every missing feature (design rule: "Presence is off on the open keyset", never a + * guess from a failed call). Registered last in run.ts, so these checks run after every other P0 preflight. + * + * Per role: + * 1. With PUBNUB_ADMIN_API_KEY and a keyset id, read the keyset configuration through the Admin API (read only, + * keyset-features.ts). Without them, this part is skipped and says so. + * 2. Confirm each feature with one SDK probe. Features with their own preflight step (Presence, Message Persistence, + * Delete-From-History, App Context, Files, Mobile Push) are not probed twice: their recorded results are collected. + * The features without one are probed here: Stream Controller wildcard subscribe and App Context events on the open + * keyset, Access Manager with token revocation on the pam keyset, Access Manager and the DataSync classes on the datasync + * keyset. + * 3. Fail once, with every missing feature in one message. Nothing is ever changed on a keyset. + * + * DataSync event rules have no API. They stay a probe in api-reference/datasync.ts (`datasyncEvents`, skips as + * needs-feature:datasync-events). + */ +const OTHER_PREFLIGHTS: Record = { + preflightKeyset: 'working keys (publish)', + preflightPersistence: 'Message Persistence', + preflightDeleteFromHistory: 'Message Persistence Delete-From-History', + preflightPresence: 'Presence with tracking on all channels', + preflightAppContext: 'App Context', + preflightFiles: 'Files', + preflightMobilePush: 'Mobile Push Notifications', +}; + +export default page('harness/preflight-summary', 'P0', (world) => { + /** Admin API part, shared by the three checks. Returns missing features and a note for the log. */ + const adminPart = async (role: Role): Promise<{ missing: string[]; note: string }> => { + try { + const read = await readFeatures(world.env, role); + if (read.status === 'read') + return { + missing: missingFeatures(role, read.config), + note: `${role} keyset configuration read through the Admin API`, + }; + if (read.status === 'mismatch') + return { missing: [`the Admin API keyset id for role ${role} is wrong: ${read.why}`], note: '' }; + return { missing: [], note: `Admin API read of the ${role} keyset skipped (${read.why}), SDK probes only` }; + } catch (error) { + return { + missing: [], + note: `Admin API read of the ${role} keyset failed (${(error as Error).message}), SDK probes only`, + }; + } + }; + + const finish = (role: Role, missing: string[], notes: string[]): void => { + for (const note of notes.filter(Boolean)) console.log(note); + // Each feature once, even when the Admin API and a probe both report it. + const unique = [...new Set(missing)]; + assert.deepEqual(unique, [], `features of the ${role} keyset`); + }; + + check( + 'preflightFeaturesOpen', + { + timeout: 90_000, + proves: 'Preflight: the open keyset has every feature the open samples need, named in one message when not.', + }, + async () => { + const { missing, note } = await adminPart('open'); + for (const [id, feature] of Object.entries(OTHER_PREFLIGHTS)) { + const result = registry.result(id); + if (result?.status === 'failed') + missing.push(`${feature} is not usable on the open keyset (preflight ${id} failed: ${result.error ?? ''})`); + if (result?.status === 'skipped' && result.reason?.startsWith('needs-feature:')) + missing.push(`${feature} is off on the open keyset (${result.reason})`); + } + // Stream Controller with wildcard subscribe: a wildcard subscription receives a publish to a matching channel. + try { + const root = world.n('preflight_wild'); + const listener = await watch(world.fresh('preflightwild'), [`${root}.*`]); + try { + await world.observer.publish({ channel: `${root}.probe`, message: { wildcard: true } }); + await listener.next('message', (m) => m.channel === `${root}.probe`, { timeout: 10_000 }); + } finally { + listener.stop(); + } + } catch (error) { + explainError(error); + missing.push( + `Stream Controller wildcard subscribe is not working on the open keyset (${(error as Error).message})`, + ); + } + // App Context events: a subscriber of a channel receives the objects event of its channel metadata. + try { + const channel = world.n('preflight_channel'); + const listener = await watch(world.fresh('preflightobjects'), [channel]); + try { + await world.observer.objects.setChannelMetadata({ channel, data: { name: 'preflight' } }); + await listener.next('objects', (e) => e.channel === channel, { timeout: 15_000 }); + } finally { + listener.stop(); + await world.observer.objects.removeChannelMetadata({ channel }).catch(() => undefined); + } + } catch (error) { + explainError(error); + missing.push( + `App Context channel metadata events are not delivered on the open keyset (${(error as Error).message})`, + ); + } + finish('open', missing, [note]); + }, + ); + + /** Access Manager probe for a secured role: grant a token, and with `revoke` also revoke it. */ + const accessManagerProbe = async (role: 'pam' | 'datasync', revoke: boolean, missing: string[]): Promise => { + let token: string | undefined; + try { + token = await world.server.grantToken({ + ttl: 1, + authorized_uuid: world.n('am-v2-user'), + resources: { channels: { [world.n('am_other')]: { read: true } } }, + }); + assert.ok(token, 'grantToken returned a token'); + } catch (error) { + explainError(error); + missing.push(`Access Manager is not usable on the ${role} keyset (${(error as Error).message})`); + return; + } + if (!revoke) return; + try { + await world.server.revokeToken(token); + } catch (error) { + explainError(error); + missing.push(`Access Manager token revocation is off on the ${role} keyset (${(error as Error).message})`); + } + }; + + check( + 'preflightFeaturesPam', + { + keyset: 'pam', + timeout: 60_000, + proves: 'Preflight: the pam keyset has Access Manager with token revocation, named in one message when not.', + }, + async () => { + const { missing, note } = await adminPart('pam'); + await accessManagerProbe('pam', true, missing); + finish('pam', missing, [note]); + }, + ); + + check( + 'preflightFeaturesDatasync', + { + keyset: 'datasync', + timeout: 60_000, + proves: + 'Preflight: the datasync keyset has Access Manager, DataSync and the product and ProductOwner classes, named in one message when not.', + }, + async () => { + const { missing, note } = await adminPart('datasync'); + await accessManagerProbe('datasync', false, missing); + const ds = world.server.dataSync; + try { + await ds.getUsers({ limit: 1 }); + } catch (error) { + explainError(error); + missing.push(`DataSync is not usable on the datasync keyset (${(error as Error).message})`); + } + for (const [label, read] of [ + ['class product v1', () => ds.getEntities({ class: 'product', classVersion: 1, limit: 1 })], + ['class ProductOwner v1', () => ds.getRelationships({ class: 'ProductOwner', classVersion: 1, limit: 1 })], + ] as const) { + try { + await eventually( + async () => { + await read(); + }, + { timeout: 5_000, label }, + ); + } catch (error) { + missing.push(`DataSync ${label} is missing on the datasync keyset (run npm run docs-snippets:provision)`); + } + } + finish('datasync', missing, [note, 'DataSync event rules have no API, the step datasyncEvents probes them']); + }, + ); +}); diff --git a/docs-snippets/harness/preflight.ts b/docs-snippets/harness/preflight.ts new file mode 100644 index 000000000..ec2ab048c --- /dev/null +++ b/docs-snippets/harness/preflight.ts @@ -0,0 +1,64 @@ +import assert from './assert.js'; +import { explainError } from './guard.js'; +import { preflightMessage } from './preflight-errors.js'; +import { page, check } from './step.js'; +import { eventually, PRESENCE_TIMEOUT, watch } from './wait.js'; + +/** + * Keyset capability checks. The first live run showed that a keyset without Presence, + * Storage Delete or App Context makes many snippets fail with the same opaque 403. A preflight turns that into + * one named failure, and steps that `needs` it are skipped as `blocked-by:`, so one root cause + * shows as one failure. + */ +export default page('harness/preflight', 'P0', (world) => { + // One throw per cause, with a message that names the Admin Portal setting. `explainError` first appends the service's + // own message to the SDK's generic one, so the report line explains itself. + const fail = (feature: string, setting: string, error: unknown): never => { + explainError(error); + throw new Error(preflightMessage(error, 'open', feature, setting)); + }; + + // Keys and network first, so a mistyped key or an offline machine reads as that and not as three missing features. + check('preflightKeyset', { proves: 'Preflight: the open keyset keys work and the network is reachable, shown by a publish.' }, async () => { + try { + await world.observer.publish({ channel: world.n('preflight_channel'), message: { preflight: true } }); + } catch (error) { + fail('working keys (publish and subscribe)', 'a publish and subscribe key pair', error); + } + }); + + // Each preflight below depends on the previous one, so one broken key shows one failure and the rest are blocked-by. + check('preflightPersistence', { needs: ['preflightKeyset'], proves: 'Preflight: Message Persistence is enabled on the open keyset.' }, async () => { + try { + await world.observer.fetchMessages({ channels: [world.n('preflight_channel')], count: 1 }); + } catch (error) { + fail('Message Persistence', 'Message Persistence', error); + } + }); + + check('preflightDeleteFromHistory', { needs: ['preflightPersistence'], proves: 'Preflight: Delete-From-History is enabled on the open keyset.' }, async () => { + // Deleting an empty range on an unused channel has no side effect. 403 means the setting is off. + const now = (await world.observer.time()).timetoken; + try { + await world.observer.deleteMessages({ channel: world.n('preflight_channel'), start: (BigInt(now) - 1n).toString(), end: now }); + } catch (error) { + fail('"delete from history"', 'Delete-From-History (Storage Delete)', error); + } + }); + + // Presence has no cheap read-only probe. Subscribe a fresh client and wait until hereNow lists it. That depends on + // heartbeats, hence the longer timeout and the short-heartbeat client options. + check('preflightPresence', { needs: ['preflightKeyset'], timeout: 60_000, proves: 'Preflight: Presence is enabled and tracks a subscribed client in hereNow.' }, async () => { + const channel = world.n('preflight_channel'); + const client = world.fresh('preflightpresence', world.presenceOptions); + await watch(client, [channel]); + try { + await eventually(async () => { + const here = await world.observer.hereNow({ channels: [channel] }); + assert.equal(here.channels[channel]?.occupancy, 1, 'hereNow does not list the subscribed client'); + }, { timeout: PRESENCE_TIMEOUT, label: 'client visible in hereNow' }); + } catch (error) { + fail('Presence', 'Presence', error); + } + }); +}); diff --git a/docs-snippets/harness/proxy.ts b/docs-snippets/harness/proxy.ts new file mode 100644 index 000000000..68ff68b63 --- /dev/null +++ b/docs-snippets/harness/proxy.ts @@ -0,0 +1,75 @@ +import http from 'node:http'; +import net from 'node:net'; +import type { AddressInfo } from 'node:net'; + +/** + * Local HTTP CONNECT proxy on 127.0.0.1 (`setProxy`). It tunnels to the real host and + * counts CONNECT requests, so a step can prove traffic went through the proxy and stopped after `setProxy()`. + * `outage(true)` refuses new tunnels and destroys open ones, for the later status-event steps. + */ +// An HTTP CONNECT proxy is enough because the SDK talks TLS to the service. The proxy never sees plaintext, it only pipes bytes. +// `http.createServer` rejects plain requests with 405 for the same reason. +export class LocalProxy { + private server?: http.Server; + private sockets = new Set(); + private blocked = false; + connects = 0; + port = 0; + readonly host = '127.0.0.1'; + + /** Listens on an ephemeral port (`listen(0)`), so parallel runs never collide. Idempotent. */ + async start(): Promise { + if (this.server) return; + this.server = http.createServer((_req, res) => { + res.writeHead(405).end('CONNECT only'); + }); + this.server.on('connect', (req, clientSocket: net.Socket, head) => { + this.connects += 1; + if (this.blocked) { + clientSocket.end('HTTP/1.1 503 Service Unavailable\r\n\r\n'); + return; + } + const [hostname, port] = (req.url ?? '').split(':'); + const upstream = net.connect(Number(port) || 443, hostname, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + // Track both ends so `outage(true)` and `stop()` can cut established tunnels, which a refused new CONNECT would not. + this.sockets.add(clientSocket); + this.sockets.add(upstream); + const drop = (): void => { + this.sockets.delete(clientSocket); + this.sockets.delete(upstream); + clientSocket.destroy(); + upstream.destroy(); + }; + upstream.on('error', drop); + clientSocket.on('error', drop); + upstream.on('close', drop); + clientSocket.on('close', drop); + }); + await new Promise((resolve) => this.server!.listen(0, this.host, resolve)); + this.port = (this.server.address() as AddressInfo).port; + } + + /** Spread into `setProxy({...})` to point a snippet at this proxy. */ + get config(): Partial<{ hostname: string; port: number; protocol: string }> { + return { hostname: this.host, port: this.port, protocol: 'http' }; + } + + /** Simulates a network outage: refuses new tunnels with 503 and cuts the open ones, so the SDK reports an unexpected disconnect. */ + outage(on: boolean): void { + this.blocked = on; + if (on) for (const socket of this.sockets) socket.destroy(); + } + + async stop(): Promise { + for (const socket of this.sockets) socket.destroy(); + this.sockets.clear(); + const server = this.server; + this.server = undefined; + if (server) await new Promise((resolve) => server.close(() => resolve())); + } +} diff --git a/docs-snippets/harness/quiet.ts b/docs-snippets/harness/quiet.ts new file mode 100644 index 000000000..7cae6a417 --- /dev/null +++ b/docs-snippets/harness/quiet.ts @@ -0,0 +1,64 @@ +/** + * Keeps the console quiet while steps run, without losing anything. + * + * Why: docs snippets create clients with `logLevel: Debug` or `Trace` and the SDK's default console logger prints every + * entry (`PubNub-00ead8ce Trace EventEngine Leave all with parameters`), also later when `world.dispose()` destroys the + * client. A run with hundreds of such lines hides the Mocha result and is hard to redact by eye. The lines are not noise to + * the steps that assert them, those steps replace `console` themselves (`world.captureConsole`) or pass a `loggers` array. + * + * How: this module replaces the console methods ONCE per process with thin wrappers. While muted a wrapper stores the formatted + * line (a step can fetch them with `unmute()`), otherwise it calls the original. The wrappers are never removed, because + * `world.captureConsole` saves "the current console method" and restores it later, and that must stay valid. + * The stdout/stderr redaction (redact.cjs) is untouched: whatever finally prints is still masked, and the lines kept for the + * report are masked by the reporter. + * + * Off switch: set `DOCS_SNIPPETS_LOG_LEVEL` (you asked for logs) or `DOCS_SNIPPETS_VERBOSE=1` and nothing is muted. + */ +import { format } from 'node:util'; + +const METHODS = ['log', 'info', 'debug', 'warn', 'error', 'trace'] as const; +type Method = (typeof METHODS)[number]; + +let installed = false; +let enabled = true; +let muted = false; +let buffer: string[] = []; +// A Trace logger can print thousands of lines per step. Only the newest KEEP lines are stored, the total is still counted. +const KEEP = 500; +let total = 0; +const originals = {} as Record void>; + +/** Installs the wrappers. Idempotent. `enabled` false means "never mute" (you want to see the logs). */ +export function installQuiet(options: { enabled: boolean }): void { + enabled = options.enabled; + if (installed) return; + installed = true; + for (const method of METHODS) { + originals[method] = console[method].bind(console) as (...args: unknown[]) => void; + console[method] = (...args: unknown[]): void => { + if (muted && enabled) { + total += 1; + buffer.push(`[console.${method}] ${format(...args)}`); + if (buffer.length > KEEP) buffer.shift(); + } + else originals[method](...args); + }; + } +} + +/** Starts collecting console output instead of printing it. */ +export function mute(): void { + muted = true; + buffer = []; + total = 0; +} + +/** Stops collecting and returns the newest collected lines since `mute()` and how many lines there were in total. */ +export function unmute(): { lines: string[]; total: number } { + muted = false; + const lines = buffer; + const count = total; + buffer = []; + total = 0; + return { lines, total: count }; +} diff --git a/docs-snippets/harness/recorder.ts b/docs-snippets/harness/recorder.ts new file mode 100644 index 000000000..e83b6b0be --- /dev/null +++ b/docs-snippets/harness/recorder.ts @@ -0,0 +1,123 @@ +/** + * Assertion recorder: remembers every assertion a step executed, so the run report can show what a step really checked. + * + * Why it exists: the JSON report used to say "passed" or "failed" per step and nothing about what the step proved. A human + * reading a green run could not tell a step with ten assertions from a step with none. `harness/assert.ts` (the recorded + * drop-in for `node:assert/strict`) and the wait helpers (`eventually`, `expectEvent`, `connected`) call `record()`, the + * Mocha binding (mocha-entry.ts) brackets each step with `begin()` and `end()` and hands the result to reporter.cjs. + * + * Two details worth knowing: + * - Polling. `eventually` runs its callback many times and most attempts fail on purpose. Each attempt records into a private + * buffer (AsyncLocalStorage, so unrelated timers cannot leak into it). Only the buffer of the attempt that passed is kept. + * - Size. Repeated identical entries collapse into one entry with a count, and a step keeps at most MAX_ENTRIES entries. + * + * Nothing here changes whether an assertion passes. It only observes. + */ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { inspect } from 'node:util'; +import { redact } from './redact.cjs'; + +/** One assertion (or one wait that behaves like one) as the report shows it. */ +export interface AssertionRecord { + /** `equal`, `deepEqual`, `ok`, `match`, `rejects`, `eventually`, `event`, `connected` and so on. */ + kind: string; + /** The message the step gave, or a generated one that shows the values. */ + label: string; + ok: boolean; + /** How many identical consecutive assertions this entry stands for. */ + count: number; + /** Failed assertions only: the values the assertion compared, already shortened. */ + expected?: string; + actual?: string; + /** Failed assertions only: the first stack frame inside the suite, as `file:line:column`. */ + frame?: string; +} + +const MAX_ENTRIES = 200; + +/** Shortens any value for a report line. Never throws, never prints more than `max` characters. */ +export function short(value: unknown, max = 80): string { + let text: string; + try { + // No maxStringLength: inspect would cut a long string inside an object, and a key cut in half escapes redaction. + text = typeof value === 'string' ? JSON.stringify(value) : inspect(value, { depth: 2, breakLength: Infinity, maxArrayLength: 8 }); + } catch { + text = String(value); + } + // Redact the whole text first, then shorten, so no key can be cut into a piece that matches neither its value nor its shape. + text = redact(text); + return text.length > max ? `${text.slice(0, max - 3)}...` : text; +} + +/** + * First stack frame that belongs to the suite (a page module, shared snippet or helper) and not to the recording layer, Node + * or a dependency. The staged copy lives in `.run//`, the prefix is cut so the frame names the source file, whose + * line numbers are the same (the stage rewrite changes string literals only, never lines). + */ +export function appFrame(stack: string | undefined): string | undefined { + if (!stack) return undefined; + for (const line of stack.split('\n').slice(1)) { + if (/node:internal|node_modules|harness\/(assert|recorder)\./.test(line)) continue; + const match = /\(?((?:file:\/\/)?[^()\s]+:\d+:\d+)\)?\s*$/.exec(line.trim()); + if (!match) continue; + return match[1].replace(/^file:\/\//, '').replace(/^.*?\.run\/[0-9a-z]+\//, '').replace(/^.*?\/docs-snippets\//, ''); + } + return undefined; +} + +class Recorder { + private entries: AssertionRecord[] | undefined; + private dropped = 0; + /** Assertions recorded into the running step so far, merged and dropped ones included. The honesty check reads it. */ + total = 0; + private readonly scope = new AsyncLocalStorage(); + + /** Starts a fresh list for the step that is about to run. */ + begin(): void { + this.entries = []; + this.dropped = 0; + this.total = 0; + } + + /** Returns what the step recorded and stops recording. `dropped` counts entries beyond the cap. */ + end(): { assertions: AssertionRecord[]; dropped: number } { + const result = { assertions: this.entries ?? [], dropped: this.dropped }; + this.entries = undefined; + this.dropped = 0; + return result; + } + + /** Adds an entry to the current polling attempt if there is one, otherwise to the running step. */ + record(entry: Omit): void { + this.push(this.scope.getStore() ?? this.entries, { ...entry, count: 1 }); + } + + /** Appends already finished entries (the passing attempt of a poll) to the current list. */ + commit(buffer: AssertionRecord[]): void { + const target = this.scope.getStore() ?? this.entries; + for (const entry of buffer) this.push(target, entry); + } + + /** Runs `fn` with a private buffer, so its failed attempts can be thrown away. */ + attempt(buffer: AssertionRecord[], fn: () => T): T { + return this.scope.run(buffer, fn); + } + + private push(target: AssertionRecord[] | undefined, entry: AssertionRecord): void { + if (!target) return; + if (target === this.entries) this.total += entry.count; + const last = target[target.length - 1]; + if (last && last.kind === entry.kind && last.label === entry.label && last.ok === entry.ok && entry.ok) { + last.count += entry.count; + return; + } + if (target.length >= MAX_ENTRIES && target === this.entries) { + this.dropped += entry.count; + return; + } + target.push(entry); + } +} + +/** One recorder per process. The staged mocha-entry and the staged page modules share this instance. */ +export const recorder = new Recorder(); diff --git a/docs-snippets/harness/redact.cjs b/docs-snippets/harness/redact.cjs new file mode 100644 index 000000000..0a6b13619 --- /dev/null +++ b/docs-snippets/harness/redact.cjs @@ -0,0 +1,162 @@ +'use strict'; +/** + * Secret masking. Nothing the suite prints or writes may contain a key, locally or in CI. GitHub masks secrets in job logs, + * but not in uploaded artifacts, and a local terminal has no masking at all, so this module is the protection everywhere. + * + * Masked: + * - the exact values of every configured key (the env names below, plus values registered with `addSecrets`, for example + * keys the loader read from a local env file), and their URL-encoded forms (secret keys can contain `+`, `/` and `=`, + * which a URL carries as `%2B`, `%2F` and `%3D`). A masked value keeps no characters, only `[redacted]`. + * - any key shaped like `pub-c-`, `sub-c-` or `sec-c-`, and Access Manager tokens, also when + * they are URL-encoded. + * + * Covered output paths: + * - `process.stdout` and `process.stderr` writes of any kind (strings, Buffers, any typed array). Writes are buffered up to + * the end of the line, so a key split across two writes is masked whole. A partial line is flushed after 100 ms, when the + * buffer is large, and at exit. + * - console output (it goes through those streams), Mocha output, SDK logger output. + * - an uncaught exception outside Mocha. Node would print it straight to the file descriptor and bypass the streams, so a + * handler prints the redacted stack instead. When another handler is installed (Mocha has one while tests run), that + * handler owns the error and this one stays out of the way. + * - files written by the reporter (they call `redact()` before writing). + * + * This is a CommonJS file because the Mocha reporter is CommonJS and the harness imports it too. + */ +// Keep in sync with SECRET_NAMES in env.ts (this file is CommonJS and cannot import it). +const KEY_VARS = [ + 'PUBLISH_KEY', + 'SUBSCRIBE_KEY', + 'PAM_PUBLISH_KEY', + 'PAM_SUBSCRIBE_KEY', + 'PAM_SECRET_KEY', + 'DS_PUBLISH_KEY', + 'DS_SUBSCRIBE_KEY', + 'DS_SECRET_KEY', + 'PUBNUB_ADMIN_API_KEY', +]; +// One character of a base64 or base64url value, plain or URL-encoded. +const B64 = '(?:[A-Za-z0-9_=+/-]|%2[BbFf]|%3[Dd])'; +const SHAPES = [ + [/\b(pub|sub)-c-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, (m) => `${m.slice(0, 6)}***`], + [new RegExp(`\\bsec-c-${B64}{16,}`, 'g'), () => 'sec-c-***'], + // Access Manager tokens are base64 of a CBOR map whose first field is "v". The first four characters encode the map size, + // so they differ by shape: `p0F2` for a token without an authorized user id, `qEF2` with one, and so on. This matches every + // map size from 1 to 23 (`oUF2` to `t0F2`). + [new RegExp(`\\b[o-t][0EUk]F2${B64}{24,}`, 'g'), () => '[token]'], +]; + +// Process-wide state. The source tree and the staged copy under .run// each load their own copy of this module, so +// the secret list and the "installed" flag live on a global symbol that every copy shares. +const STATE = Symbol.for('docs-snippets.redaction'); +const state = globalThis[STATE] || (globalThis[STATE] = { extra: new Set(), installed: false }); +// Secrets that are not env vars (values the loader read from a local env file). +const extra = state.extra; + +/** Registers more literal secrets to mask, for example values read from a local env file. */ +function addSecrets(values) { + for (const value of values) if (typeof value === 'string' && value.trim().length >= 8) extra.add(value.trim()); +} + +/** Every literal form of a secret that can appear in output: the value and its URL-encoded form. */ +function forms(value) { + const out = [value]; + const encoded = encodeURIComponent(value); + if (encoded !== value) out.push(encoded, encoded.toLowerCase()); + return out; +} + +function literalSecrets(env) { + const out = new Set(); + for (const value of extra) for (const form of forms(value)) out.add(form); + for (const name of KEY_VARS) { + const value = env[name]; + if (value && value.trim().length >= 8) for (const form of forms(value.trim())) out.add(form); + } + // Longest first, so a secret that contains another one is masked whole and not left with a tail. + return [...out].sort((a, b) => b.length - a.length); +} + +/** Literal secrets become `[redacted]`. Shaped keys keep their kind (`pub-c-`, `sub-c-`) and nothing else. */ +function redact(text, env = process.env) { + let out = String(text); + for (const secret of literalSecrets(env)) out = out.split(secret).join('[redacted]'); + for (const [pattern, replace] of SHAPES) out = out.replace(pattern, replace); + return out; +} + +/** Text of any chunk a stream accepts. Undefined for chunks that carry no text (they pass through unchanged). */ +function chunkText(chunk) { + if (typeof chunk === 'string') return chunk; + if (Buffer.isBuffer(chunk)) return chunk.toString('utf8'); + if (ArrayBuffer.isView(chunk)) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString('utf8'); + return undefined; +} + +const FLUSH_MS = 100; +const MAX_PENDING = 16 * 1024; + +/** Wraps one stream: buffers to the end of the line, redacts, then writes with the original `write`. */ +function wrapStream(stream) { + const write = stream.write.bind(stream); + let pending = ''; + let timer; + const flush = () => { + if (timer) clearTimeout(timer); + timer = undefined; + if (!pending) return; + const text = pending; + pending = ''; + write(redact(text)); + }; + stream.write = (chunk, encoding, callback) => { + const done = typeof encoding === 'function' ? encoding : callback; + const text = chunkText(chunk); + if (text === undefined) return write(chunk, encoding, callback); + const all = pending + text; + const end = all.lastIndexOf('\n'); + let result = true; + if (end === -1) { + pending = all; + } else { + pending = all.slice(end + 1); + result = write(redact(all.slice(0, end + 1))); + } + if (pending.length > MAX_PENDING) flush(); + else if (pending && !timer) { + timer = setTimeout(flush, FLUSH_MS); + timer.unref(); + } + if (done) process.nextTick(done); + return result; + }; + return flush; +} + +/** + * Installs the stream wrappers and the uncaught exception fallback. Process-wide and idempotent, which is why every entry + * point (cli, mocha-entry, provision) calls it first. + */ +function installRedaction() { + if (state.installed) return; + state.installed = true; + const flushers = [process.stdout, process.stderr].map(wrapStream); + const flushAll = () => flushers.forEach((flush) => flush()); + process.on('beforeExit', flushAll); + process.on('exit', flushAll); + process.on('uncaughtException', (error) => { + // Another handler (Mocha while it runs) owns the error. Only when this is the last one, act like Node, redacted. + if (process.listenerCount('uncaughtException') > 1) return; + flushAll(); + const text = error && error.stack ? error.stack : String(error); + process.stderr.write(`${redact(text)}\n`); + flushAll(); + process.exit(1); + }); +} + +/** The literal secrets currently known (for the reporter self-test only, never print them). */ +function knownSecrets(env = process.env) { + return literalSecrets(env); +} + +module.exports = { redact, addSecrets, installRedaction, knownSecrets }; diff --git a/docs-snippets/harness/redact.d.cts b/docs-snippets/harness/redact.d.cts new file mode 100644 index 000000000..55dc0ed7b --- /dev/null +++ b/docs-snippets/harness/redact.d.cts @@ -0,0 +1,5 @@ +export function redact(text: string, env?: Record): string; +export function addSecrets(values: Array): void; +export function installRedaction(): void; +/** Literal secrets known to the redaction. Only for the reporter self-test, which checks output against them. Never print. */ +export function knownSecrets(env?: Record): string[]; diff --git a/docs-snippets/harness/registry.ts b/docs-snippets/harness/registry.ts new file mode 100644 index 000000000..311f5cee4 --- /dev/null +++ b/docs-snippets/harness/registry.ts @@ -0,0 +1,291 @@ +/** + * Step registry: the single source of truth for what the suite will run and in which order. + * + * Page modules register steps through `step()`, `check()` and `stub()` (step.ts). The registry validates the wiring + * (ids unique, `needs` point at earlier steps), sorts by phase, and answers one question for the Mocha binding: + * "may this step run now?" (`skipReason`). It also remembers each step's result, because `needs` is evaluated against + * results at run time: a step runs only when every step it needs has passed. + * + * The dry run drives the very same code with fake bodies (`simulate`), so skip-on-upstream-failure is proven without + * a network. There is exactly one registry per process (the exported `registry`). The staged copy under + * `.run//` loads its own instance of this module, which is why `cli.ts dry-run` imports the registry back out + * of the staged `run.ts` before inspecting it. + */ +import type { Role } from './env.js'; +import { assertAllowedSkip } from './skips.js'; + +/** Phases P0 to P9 run in this order. */ +export type Phase = 'P0' | 'P1' | 'P2' | 'P3' | 'P4' | 'P5' | 'P6' | 'P7' | 'P8' | 'P9'; +export const PHASES: readonly Phase[] = ['P0', 'P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8', 'P9']; +/** + * The role a step runs as, which decides its keyset and env names (env.ts): `open` has no Access Manager, `pam` has Access + * Manager (v2 and v3), `datasync` has DataSync classes plus Access Manager. An older layout had one `secured` keyset, which is + * split here into `pam` and `datasync`. + */ +export type Keyset = Role; +export type StepStatus = 'passed' | 'failed' | 'skipped' | 'pending'; + +export interface StepOptions { + /** Ids of steps that must have passed. Otherwise this step is skipped with `blocked-by:`. */ + needs?: string[]; + /** Overrides the page phase. */ + phase?: Phase; + /** Role (keyset) the step talks to. Defaults to `open`. */ + keyset?: Keyset; + /** Allowed skip reason (see skips.ts). The step is registered, type-checked and reported as skipped. */ + skipIf?: string; + /** Snippet ids the step runs. Defaults to `[id]`. Must equal the markers inside the step body (lint). */ + snippets?: string[]; + /** Mocha timeout in ms. Defaults to 30000. */ + timeout?: number; + /** + * One short sentence for the run report: what this step proves. Written from the comment block above the step. It is + * shown next to the step in report/.md and .html, so a reader sees intent without opening the code. + */ + proves?: string; +} + +export interface StepDef { + id: string; + /** Page path such as `api-reference/publish`. */ + page: string; + phase: Phase; + keyset: Keyset; + needs: string[]; + skipIf?: string; + /** Snippet ids. Empty for `check()` steps. */ + snippets: string[]; + kind: 'step' | 'check' | 'stub'; + timeout: number; + /** One-line description for the report, see `StepOptions.proves`. */ + proves?: string; + fn: () => Promise | void; + /** Position in registration order. */ + order: number; +} + +export interface StepResult { + status: StepStatus; + reason?: string; + error?: string; + durationMs?: number; +} + +export type Availability = Record; + +/** What a filtered run executes: the selected steps plus the steps they need that the filter left out. */ +export interface Selection { + /** Step ids that run, in run order. */ + run: Set; + /** Ids that run only because a selected step needs them. Reported as prerequisites, not as passed tests. */ + prerequisites: Set; + /** Ids the filter left out and nothing needs. */ + notSelected: Set; +} + +/** Collects steps, validates their wiring, orders them by phase and tracks results for `needs`. */ +export class Registry { + private steps = new Map(); + private results = new Map(); + private currentPage = '(none)'; + private currentPhase: Phase = 'P4'; + /** Which keysets have keys. A step on a missing keyset skips as `missing-env:` instead of failing. */ + availability: Availability = { open: true, pam: true, datasync: true }; + + reset(): void { + this.steps.clear(); + this.results.clear(); + this.currentPage = '(none)'; + } + + /** Sets the page and default phase that `add` stamps on steps while `fn` runs (a page's `setup` callback). Restores them after. */ + withPage(page: string, phase: Phase, fn: () => T): T { + const previous = [this.currentPage, this.currentPhase] as const; + this.currentPage = page; + this.currentPhase = phase; + try { + return fn(); + } finally { + [this.currentPage, this.currentPhase] = previous; + } + } + + add( + kind: StepDef['kind'], + id: string, + options: StepOptions, + fn: StepDef['fn'], + ): StepDef { + // Same character set as snippet ids. The id doubles as the Mocha test title and the key in report JSON. + if (!/^[A-Za-z0-9_/-]+$/.test(id)) throw new Error(`step id "${id}" is not a valid snippet id`); + if (this.steps.has(id)) throw new Error(`duplicate step id "${id}" (already registered by ${this.steps.get(id)!.page})`); + if (options.skipIf) assertAllowedSkip(options.skipIf, `step ${id}`); + const def: StepDef = { + id, + page: this.currentPage, + phase: options.phase ?? this.currentPhase, + keyset: options.keyset ?? 'open', + needs: options.needs ?? [], + skipIf: options.skipIf, + // check() and stub() own no snippets, so the lint can assert they contain no markers. + snippets: kind === 'check' || kind === 'stub' ? [] : (options.snippets ?? [id]), + kind, + timeout: options.timeout ?? 30_000, + proves: options.proves, + fn, + order: this.steps.size, + }; + this.steps.set(id, def); + return def; + } + + get(id: string): StepDef | undefined { + return this.steps.get(id); + } + + /** Stable sort by phase, then registration order. This is the order Mocha runs the steps in. */ + ordered(): StepDef[] { + return [...this.steps.values()].sort( + (a, b) => PHASES.indexOf(a.phase) - PHASES.indexOf(b.phase) || a.order - b.order, + ); + } + + /** + * Selects steps by a predicate and adds, transitively, every step a selected step `needs`. Those run on demand as + * prerequisites with their own role's keys. With no predicate every step is selected. + */ + select(predicate?: (step: StepDef) => boolean): Selection { + const ordered = this.ordered(); + const chosen = new Set(ordered.filter((s) => !predicate || predicate(s)).map((s) => s.id)); + const needed = new Set(); + const visit = (id: string): void => { + for (const need of this.steps.get(id)?.needs ?? []) { + if (chosen.has(need) || needed.has(need)) continue; + needed.add(need); + visit(need); + } + }; + for (const id of chosen) visit(id); + const run = new Set(ordered.filter((s) => chosen.has(s.id) || needed.has(s.id)).map((s) => s.id)); + const notSelected = new Set(ordered.filter((s) => !run.has(s.id)).map((s) => s.id)); + return { run, prerequisites: needed, notSelected }; + } + + /** Every `needs` edge whose two ends run as different roles. */ + crossRoleNeeds(): Array<{ step: string; role: Role; needs: string; needsRole: Role }> { + const out: Array<{ step: string; role: Role; needs: string; needsRole: Role }> = []; + for (const step of this.ordered()) { + for (const need of step.needs) { + const target = this.steps.get(need); + if (target && target.keyset !== step.keyset) out.push({ step: step.id, role: step.keyset, needs: need, needsRole: target.keyset }); + } + } + return out; + } + + /** Wiring problems: unknown needs, needs that run later, cycles. Empty means the plan is sound. */ + validate(): string[] { + const problems: string[] = []; + const order = this.ordered(); + const position = new Map(order.map((s, i) => [s.id, i])); + for (const step of order) { + for (const need of step.needs) { + const target = this.steps.get(need); + if (!target) { + problems.push(`${step.id}: needs unknown step "${need}"`); + } else if (target.id === step.id) { + problems.push(`${step.id}: needs itself`); + } else if (position.get(need)! > position.get(step.id)!) { + problems.push(`${step.id} (${step.phase}) needs "${need}" (${target.phase}) which runs later`); + } + } + if (step.kind === 'step' && step.snippets.length === 0) problems.push(`${step.id}: step has no snippet ids`); + } + return problems; + } + + /** Why a step cannot run right now, or undefined when it can. Pure with respect to recorded results. */ + skipReason(step: StepDef): string | undefined { + // Precedence: a declared skip wins, then missing keys, then an upstream step that did not pass. + if (step.skipIf) return step.skipIf; + if (!this.availability[step.keyset]) return `missing-env:${step.keyset}`; + for (const need of step.needs) { + const result = this.results.get(need); + if (!result || result.status !== 'passed') return `blocked-by:${need}`; + } + return undefined; + } + + /** Called by the Mocha binding after each step. `needs` of later steps read these results. */ + record(id: string, result: StepResult): void { + this.results.set(id, result); + } + + result(id: string): StepResult | undefined { + return this.results.get(id); + } + + allResults(): Array<{ step: StepDef; result: StepResult }> { + return this.ordered().map((step) => ({ step, result: this.results.get(step.id) ?? { status: 'pending' } })); + } + + /** + * Runs the same skip logic the Mocha binding uses, with fake bodies. `failing` lists step ids that fail. + * Used by the dry run to prove skip-on-upstream-failure without a network. + */ + simulate(failing: string[]): Array<{ id: string; status: StepStatus; reason?: string }> { + const saved = new Map(this.results); + this.results.clear(); + const out: Array<{ id: string; status: StepStatus; reason?: string }> = []; + for (const step of this.ordered()) { + const reason = this.skipReason(step); + if (reason) { + this.results.set(step.id, { status: 'skipped', reason }); + out.push({ id: step.id, status: 'skipped', reason }); + } else if (failing.includes(step.id)) { + this.results.set(step.id, { status: 'failed' }); + out.push({ id: step.id, status: 'failed' }); + } else { + this.results.set(step.id, { status: 'passed' }); + out.push({ id: step.id, status: 'passed' }); + } + } + this.results = saved; + return out; + } +} + +export const registry = new Registry(); + +/** The Mocha title of a step. It carries the snippet ids, so `--grep ` finds the step. */ +export function stepTitle(def: StepDef): string { + const ids = def.snippets.length > 0 ? ` [${def.snippets.join(', ')}]` : ''; + return `${def.phase} ${def.page} :: ${def.id}${ids}`; +} + +export interface SelectionFilters { + shard?: Role; + grep?: string; + only?: string[]; +} + +/** The selection of a run, shared by the CLI (to decide setup work) and the Mocha binding (to create tests). */ +export function selectSteps(reg: Registry, filters: SelectionFilters): Selection { + const grep = filters.grep ? new RegExp(filters.grep) : undefined; + const filtered = Boolean(grep || filters.only || filters.shard); + return reg.select( + filtered + ? (def) => + (!filters.shard || def.keyset === filters.shard) && + (!grep || grep.test(stepTitle(def))) && + (!filters.only || filters.only.includes(def.id)) + : undefined, + ); +} + +/** Roles that run (selected steps and their prerequisites). `open` always counts: every role uses the open cast. */ +export function rolesOf(reg: Registry, selection: Selection): Set { + const roles = new Set(reg.ordered().filter((d) => selection.run.has(d.id)).map((d) => d.keyset)); + roles.add('open'); + return roles; +} diff --git a/docs-snippets/harness/reporter.cjs b/docs-snippets/harness/reporter.cjs new file mode 100644 index 000000000..50e252207 --- /dev/null +++ b/docs-snippets/harness/reporter.cjs @@ -0,0 +1,232 @@ +'use strict'; +/** + * Mocha reporter of the docs snippet suite. Extends Mocha's Spec reporter (the console output stays the familiar one) and + * writes, when the run ends: + * + * docs-snippets/report/run.json per step: step id, snippet ids, page, role, status, skip reason, duration, assertion + * count, prerequisite flag, the error. Plus the SDK version and commit, the run id, the + * selection, per-phase durations, the skip list and any disallowed skip. + * $GITHUB_STEP_SUMMARY a Markdown summary, when the variable is set (GitHub Actions). + * + * Everything written goes through `redact()` first. GitHub masks secrets in logs, not in uploaded artifacts, so this is the + * only protection of report/run.json. CommonJS because Mocha loads reporters with `require`, which is also why the allowed + * skip patterns repeat harness/skips.ts. Keep both in sync. + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const Mocha = require('mocha'); +const { redact } = require('./redact.cjs'); + +const { Spec } = Mocha.reporters; +const ALLOWED = /^(browser-only|react-native|needs-real-device|needs-feature:[a-z0-9-]+)$/; +const RUNNER = /^(blocked-by:.+|missing-env:(open|pam|datasync))$/; +const ANSI = /\u001b\[[0-9;]*m/g; +const PHASES = ['P0', 'P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8', 'P9']; + +/** SDK version and commit of the checkout under test. The suite lives in the SDK repository, two levels above harness/. */ +function sdkInfo() { + const root = path.resolve(__dirname, '..', '..'); + let version = null; + let commit = null; + try { + version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + } catch (e) { + version = null; + } + try { + commit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch (e) { + commit = process.env.GITHUB_SHA || 'unknown'; + } + return { version, commit }; +} + +function message(error) { + return String((error && error.message) || error) + .replace(ANSI, '') + .split('\n') + .slice(0, 25) + .join('\n'); +} + +/** One step record from a Mocha test. `pnMeta`, `pnReason` and `pnNote` are tags that mocha-entry.ts put on the test. */ +function entry(test, status, error) { + const meta = test.pnMeta || {}; + const assertions = test.pnAssertions || []; + const count = assertions.reduce((sum, a) => sum + (a.count || 1), 0) + (test.pnDropped || 0); + const bad = assertions.find((a) => !a.ok); + const record = { + id: meta.id || test.title, + snippets: meta.snippets || [], + page: meta.page || null, + phase: meta.phase || null, + kind: meta.kind || null, + role: meta.keyset || null, + prerequisite: Boolean(meta.prerequisite), + status, + reason: test.pnReason || meta.skipIf || undefined, + note: test.pnNote || undefined, + durationMs: typeof test.duration === 'number' ? test.duration : 0, + assertions: count, + proves: meta.proves || undefined, + }; + if (error) { + record.error = message(error); + if (bad) + record.failedAssertion = { label: bad.label, expected: bad.expected, actual: bad.actual, frame: bad.frame }; + if (test.pnOutput && test.pnOutput.length) record.output = test.pnOutput.slice(-30); + } + return record; +} + +/** Builds the report object. Pure, the dry run self-test calls it with simulated steps. */ +function buildReport(steps, context) { + const plan = context.plan || []; + const counted = steps.filter((s) => !s.prerequisite); + const summary = { + passed: counted.filter((s) => s.status === 'passed').length, + failed: counted.filter((s) => s.status === 'failed').length, + skipped: counted.filter((s) => s.status === 'skipped').length, + prerequisites: steps.filter((s) => s.prerequisite).length, + notSelected: plan.filter((p) => !p.selected && !p.prerequisite).length, + }; + const skips = steps + .filter((s) => s.status === 'skipped') + .map((s) => ({ + id: s.id, + reason: s.reason || '(none)', + note: s.note, + allowed: ALLOWED.test(s.reason || '') || RUNNER.test(s.reason || ''), + })); + const problems = skips + .filter((s) => !s.allowed) + .map((s) => `step ${s.id} skipped with a reason outside the allowed list: ${s.reason}`); + const phases = {}; + for (const phase of PHASES) { + const inPhase = steps.filter((s) => s.phase === phase); + if (inPhase.length) + phases[phase] = { steps: inPhase.length, ms: inPhase.reduce((sum, s) => sum + s.durationMs, 0) }; + } + const zero = steps.filter((s) => s.status === 'passed' && s.kind === 'step' && s.assertions === 0).map((s) => s.id); + return { + schema: 1, + status: summary.failed > 0 || problems.length > 0 ? 'failed' : 'passed', + runId: context.runId || null, + startedAt: context.startedAt, + finishedAt: context.finishedAt, + durationMs: context.durationMs, + sdk: context.sdk, + selection: context.selection || null, + summary, + phases, + skips, + problems, + zeroAssertionSteps: zero, + notSelected: plan.filter((p) => !p.selected && !p.prerequisite).map((p) => p.id), + steps, + }; +} + +function seconds(ms) { + return `${(ms / 1000).toFixed(1)} s`; +} + +/** The Markdown summary for GitHub Actions. Pure. */ +function renderMarkdown(report) { + const lines = []; + const shard = report.selection && report.selection.shard ? ` (shard ${report.selection.shard})` : ''; + lines.push(`## Docs snippets live run${shard}: ${report.status}`); + lines.push(''); + lines.push( + `Run \`${report.runId}\`, SDK ${report.sdk.version} at \`${String(report.sdk.commit || '').slice(0, 12)}\`, ${seconds(report.durationMs || 0)}.`, + ); + lines.push(''); + lines.push('| Passed | Failed | Skipped | Prerequisites | Not selected |'); + lines.push('| ---: | ---: | ---: | ---: | ---: |'); + const s = report.summary; + lines.push(`| ${s.passed} | ${s.failed} | ${s.skipped} | ${s.prerequisites} | ${s.notSelected} |`); + const failed = report.steps.filter((x) => x.status === 'failed'); + if (failed.length) { + lines.push('', `### Failures (${failed.length})`, ''); + for (const f of failed) + lines.push(`- \`${f.id}\` (${f.page}, role ${f.role}): ${String(f.error || '').split('\n')[0]}`); + } + if (report.skips.length) { + lines.push('', `### Skips (${report.skips.length})`, ''); + const byReason = new Map(); + for (const k of report.skips) byReason.set(k.reason, [...(byReason.get(k.reason) || []), k.id]); + for (const [reason, ids] of byReason) lines.push(`- \`${reason}\`: ${ids.map((id) => `\`${id}\``).join(', ')}`); + } + if (report.problems.length) { + lines.push('', '### Problems', ''); + for (const p of report.problems) lines.push(`- ${p}`); + } + lines.push('', '### Time per phase', '', '| Phase | Steps | Time |', '| :-- | ---: | ---: |'); + for (const [phase, v] of Object.entries(report.phases)) lines.push(`| ${phase} | ${v.steps} | ${seconds(v.ms)} |`); + const slow = [...report.steps].sort((a, b) => b.durationMs - a.durationMs).slice(0, 10); + lines.push('', '### Slowest steps', '', '| Step | Time |', '| :-- | ---: |'); + for (const x of slow) lines.push(`| \`${x.id}\` | ${seconds(x.durationMs)} |`); + return `${lines.join('\n')}\n`; +} + +/** Writes run.json and the step summary, all redacted. Returns the paths written. */ +function writeReport(report, dir) { + fs.mkdirSync(dir, { recursive: true }); + const json = redact(JSON.stringify(report, null, 2)); + const file = path.join(dir, 'run.json'); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, json); + fs.renameSync(tmp, file); + const written = [file]; + const md = redact(renderMarkdown(report)); + fs.writeFileSync(path.join(dir, 'summary.md'), md); + written.push(path.join(dir, 'summary.md')); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); + written.push(process.env.GITHUB_STEP_SUMMARY); + } + return written; +} + +class DocsSnippetsReporter extends Spec { + constructor(runner, options) { + super(runner, options); + const startedAt = new Date(); + const steps = []; + runner.on('pass', (test) => steps.push(entry(test, 'passed'))); + runner.on('fail', (test, error) => { + // Hook failures are reported too, under the hook title, so a broken root hook is never silent. + steps.push(entry(test, 'failed', error)); + }); + runner.on('pending', (test) => steps.push(entry(test, 'skipped'))); + runner.once('end', () => { + const finishedAt = new Date(); + const report = buildReport(steps, { + plan: globalThis.__pnDocsPlan, + selection: globalThis.__pnDocsSelection, + runId: process.env.DOCS_SNIPPETS_RUN_ID, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + sdk: sdkInfo(), + }); + const dir = process.env.DOCS_SNIPPETS_REPORT_DIR || path.resolve(__dirname, '..', 'report'); + try { + const written = writeReport(report, dir); + process.stdout.write(`report: ${written.join(', ')}\n`); + } catch (error) { + process.stderr.write(`report: could not write ${dir}: ${error && error.message}\n`); + } + }); + } +} + +module.exports = DocsSnippetsReporter; +module.exports.buildReport = buildReport; +module.exports.renderMarkdown = renderMarkdown; +module.exports.writeReport = writeReport; diff --git a/docs-snippets/harness/revoke.ts b/docs-snippets/harness/revoke.ts new file mode 100644 index 000000000..e1f6ad1cc --- /dev/null +++ b/docs-snippets/harness/revoke.ts @@ -0,0 +1,152 @@ +import PubNub from 'pubnub'; +import type { SecuredKeyset } from './env.js'; + +/** + * Fail-safe revoke of the application-level Access Manager (v2) grant. + * + * The `grantAllChannelsAllAuthKeys` sample grants read to everybody on the keyset. If a run dies between that grant + * and its revoke, the secured keyset stays open to strangers for the grant ttl (1440 minutes). So the revoke exists + * in four places: the step's own `finally`, the Mocha root `before` and `after` hooks, the janitor, and the signal handlers + * (they run the janitor). Every call retries and a final failure is loud. The `pam` and `datasync` keysets are shared with + * the SDK's other CI jobs, which is why the hooks also prove that a stranger is refused before and after the suite. + */ + +// Backoff is linear (1 s, 2 s, ...). The revoke is a single idempotent call, so retrying is safe, unlike a publish. +const SLEEP = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * `grant({ read: false, write: false, ttl: 1 })` on a secret-key client replaces the application-level Access Manager (v2) + * rights with none, which closes what `grantAllChannelsAllAuthKeys` opened. Throws after the last attempt, after a loud console warning + * that tells a human how to close the keyset by hand. + */ +export async function revokeApplicationGrant(client: PubNub, attempts = 3, delayMs = 1_000): Promise { + let last: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await client.grant({ read: false, write: false, ttl: 1 }); + return; + } catch (error) { + last = error; + if (attempt < attempts) await SLEEP(delayMs * attempt); + } + } + const detail = last instanceof Error ? last.message : String(last); + console.error( + '\n!!! WARNING !!! The application-level grant on the secured keyset could NOT be revoked after ' + + `${attempts} attempts (${detail}). The keyset may be OPEN for reads until the grant ttl runs out (up to 1440 minutes). ` + + 'Run `npm run test:docs-snippets:janitor` or revoke it in the Admin Portal now.\n', + ); + throw new Error(`could not revoke the application-level grant on the secured keyset: ${detail}`); +} + +/** Revokes with a short-lived client of its own. Used by the root hook, the janitor and the signal handlers. */ +// Own client: the world's `server` client may already be destroyed (signal path) or never built (root `before` hook). +export async function revokeApplicationGrantOnKeyset(secured: SecuredKeyset, tag: string, attempts = 3): Promise { + const client = new PubNub({ ...secured, userId: `janitor-am-${tag}`, logLevel: PubNub.LogLevel.None, heartbeatInterval: 0 }); + try { + await revokeApplicationGrant(client, attempts); + } finally { + client.destroy(); + } +} + +/** True when the SDK error says the keyset refused the call for lack of permission (HTTP 403, access denied category). */ +function isAccessDenied(error: unknown): boolean { + const status = (error as { status?: { category?: string; statusCode?: number } } | undefined)?.status; + return status?.category === 'PNAccessDeniedCategory' || status?.statusCode === 403; +} + +/** What one probe saw. `unknown` means it got an answer that proves nothing (network error, timeout, 5xx, 400). */ +export interface ProbeResult { + /** What a client without a token could do. Empty when the keyset refused both. */ + open: string[]; + /** Answers that were neither a success nor a 403. */ + unknown: string[]; +} + +/** + * Asks the keyset whether a client with NO token and NO auth key can publish or read. Only two answers count: success (the + * keyset is open for that) and 403 / PNAccessDeniedCategory (closed). Anything else, a network error, a timeout, a 5xx or a + * 400, is `unknown`: it says nothing about enforcement, so it must never pass as "closed". + * + * Publish needs the write permission, read (`fetchMessages`) needs read. The application-level sample grants read only, so + * the read probe is the one that notices it. The keysets this runs against have Message Persistence on (README, "Features + * each keyset needs"), so a refused read is a 403 and not a 400. + */ +export async function tokenlessAccess(probe: PubNub, channel: string): Promise { + const result: ProbeResult = { open: [], unknown: [] }; + const attempt = async (label: string, call: () => Promise): Promise => { + try { + await call(); + result.open.push(label); + } catch (error) { + if (!isAccessDenied(error)) result.unknown.push(`${label}: ${describe(error)}`); + } + }; + await attempt('publish', () => probe.publish({ channel, message: { probe: true } })); + await attempt('read', () => probe.fetchMessages({ channels: [channel], count: 1 })); + return result; +} + +function describe(error: unknown): string { + const e = error as { message?: string; status?: { category?: string; statusCode?: number } }; + return `${e?.message ?? String(error)} [${e?.status?.category ?? ''} ${e?.status?.statusCode ?? ''}]`.trim(); +} + +/** + * Closes the application-level grant and then WAITS until the keyset really enforces Access Manager. + * + * Why waiting is needed: Access Manager (v2) grants and revokes take up to about 60 seconds to propagate. A previous run + * (or a run that was cancelled) may have left a grant, and the revoke only starts the clock. A step that expects a 403 for a + * tokenless client, such as `publishHandleErrors`, would otherwise fail on a keyset that still behaves as open. The probe + * is a tokenless client (no secret key, no auth key) on a channel no run ever grants, so a grant of the same run cannot hide it. + * + * Throws with a message that names the cause when the keyset is still open after `timeoutMs`. + * Returns how long it took (`attempts` 1 means it was closed right away). + */ +export async function closeAndVerifyApplicationGrant( + secured: SecuredKeyset, + tag: string, + options: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise<{ attempts: number; ms: number }> { + const timeoutMs = options.timeoutMs ?? 120_000; + const intervalMs = options.intervalMs ?? 3_000; + await revokeApplicationGrantOnKeyset(secured, tag); + const started = Date.now(); + let attempts = 0; + let last = ''; + for (;;) { + attempts += 1; + // A fresh client (no secret key, no auth key, no token) on a fresh channel each attempt: only an application-level grant + // can let it through, because no run ever grants this channel name. + const probe = new PubNub({ + publishKey: secured.publishKey, + subscribeKey: secured.subscribeKey, + userId: `janitor-probe-${tag}-${attempts}`, + logLevel: PubNub.LogLevel.None, + heartbeatInterval: 0, + }); + let seen: ProbeResult; + try { + seen = await tokenlessAccess(probe, `docs-snippets-probe-${tag}-${Date.now().toString(36)}-${attempts}`); + } finally { + probe.destroy(); + } + // Closed only when both probes got a 403. An unknown answer is retried like an open one, never taken as closed. + if (seen.open.length === 0 && seen.unknown.length === 0) return { attempts, ms: Date.now() - started }; + last = [ + seen.open.length > 0 ? `a client without a token could ${seen.open.join(' and ')}` : '', + seen.unknown.length > 0 ? `no clear answer: ${seen.unknown.join('; ')}` : '', + ] + .filter(Boolean) + .join(', '); + if (Date.now() + intervalMs - started > timeoutMs) break; + await SLEEP(intervalMs); + } + throw new Error( + `could not prove that the secured keyset enforces Access Manager ${Math.round((Date.now() - started) / 1000)} s after the application-level grant was revoked (${last}). ` + + 'Steps that expect a 403 for a client without a token would fail. A grant from an earlier run is probably still effective: ' + + 'revoke and grant changes take up to about 60 seconds to propagate, but a grant whose revoke failed lasts up to 1440 minutes. ' + + 'Run `npm run test:docs-snippets:janitor`, check the keyset in the Admin Portal (Access Manager must be on), then run again.', + ); +} diff --git a/docs-snippets/harness/signals.ts b/docs-snippets/harness/signals.ts new file mode 100644 index 000000000..a035bf1a0 --- /dev/null +++ b/docs-snippets/harness/signals.ts @@ -0,0 +1,65 @@ +/** + * SIGINT and SIGTERM handlers. A killed run must not leave a secured keyset open or leak resources, so the handler runs the + * cleanup, then exits with the conventional code. + * + * A cancelled GitHub Actions job gets SIGINT, then SIGTERM about 7.5 s later, then SIGKILL about 2.5 s after that. So: + * - `urgent` runs first and alone: it revokes the application-level Access Manager grants, a single quick call per keyset. + * That is what must finish before SIGKILL. + * - `cleanup` (janitor, client teardown) runs after it. + * - SIGTERM never skips the cleanup. Only a second SIGINT (a person pressing Ctrl-C twice) exits at once. + * + * Exit codes follow the shell convention 128 + signal number (SIGINT 2 -> 130, SIGTERM 15 -> 143), so CI sees a cancel as + * such. `budgetMs` (100 s) caps the whole cleanup so a hung janitor cannot keep a cancelled job alive. + */ +// Written straight to stderr, not through `console`: a cancel lands in the middle of a step, while quiet.ts collects console +// output, and these lines must be seen. stderr still goes through the redaction (redact.cjs). +const say = (line: string): void => { + process.stderr.write(`${line}\n`); +}; + +// One Ctrl-C or one CI cancel reaches this process more than once: the signal goes to the whole process group, and the +// launchers in between (npm, tsx) relay it to their child too. A repeat inside this window is the same signal, not a second +// Ctrl-C. +const SAME_SIGNAL_MS = 2_000; + +export function installShutdown(urgent: () => Promise, cleanup: () => Promise, budgetMs = 100_000): void { + let running = false; + let code = 0; + let firstAt = 0; + // In CI no signal may skip the cleanup: a cancel must never leave a keyset open. + const ci = Boolean(process.env.CI || process.env.GITHUB_ACTIONS); + const handle = (signal: NodeJS.Signals, signalCode: number): void => { + if (running) { + if (signal === 'SIGINT' && !ci && Date.now() - firstAt > SAME_SIGNAL_MS) { + say('SIGINT again, exiting without waiting for cleanup'); + process.exit(signalCode); + } + if (Date.now() - firstAt > SAME_SIGNAL_MS) say(`${signal} received while cleaning up, the cleanup continues`); + return; + } + running = true; + firstAt = Date.now(); + code = signalCode; + say(` +${signal} received: revoking Access Manager grants first, then cleaning up. Press Ctrl-C again to skip.`); + const timer = setTimeout(() => { + say('cleanup took too long, exiting'); + process.exit(code); + }, budgetMs); + const step = async (label: string, fn: () => Promise): Promise => { + try { + await fn(); + } catch (error) { + say(`${label} failed: ${(error as Error)?.message ?? error}`); + } + }; + void (async () => { + await step('revoke', urgent); + await step('cleanup', cleanup); + clearTimeout(timer); + process.exit(code); + })(); + }; + process.on('SIGINT', () => handle('SIGINT', 130)); + process.on('SIGTERM', () => handle('SIGTERM', 143)); +} diff --git a/docs-snippets/harness/skips.ts b/docs-snippets/harness/skips.ts new file mode 100644 index 000000000..c0d1b92a1 --- /dev/null +++ b/docs-snippets/harness/skips.ts @@ -0,0 +1,19 @@ +/** + * The allowed-skip list. `registry.add` rejects any `skipIf` outside it, and the reporter (reporter.cjs) repeats the same + * pattern to flag a skip that slipped in anyway, so a typo can never hide a step. + * + * Skip reasons. Anything outside this list fails the run. + * + * `blocked-by:` and `missing-env:` are produced by the runner itself and + * cannot be passed to `skipIf`. + */ +export const ALLOWED_SKIP = /^(browser-only|react-native|needs-real-device|needs-feature:[a-z0-9-]+)$/; + +export function assertAllowedSkip(reason: string, where: string): void { + if (!ALLOWED_SKIP.test(reason)) { + throw new Error( + `${where}: skip reason "${reason}" is not allowed. Use browser-only, react-native, needs-real-device, ` + + `needs-feature:.`, + ); + } +} diff --git a/docs-snippets/harness/snippet-summary.cjs b/docs-snippets/harness/snippet-summary.cjs new file mode 100644 index 000000000..4680a6564 --- /dev/null +++ b/docs-snippets/harness/snippet-summary.cjs @@ -0,0 +1,144 @@ +'use strict'; +/** + * Per snippet id results for the GitHub Actions run page. + * + * Why: the reporter (reporter.cjs) summarises one shard by step, and each shard writes its own block. A reader who wants to + * know "did the sample with id X pass" has to match step ids to snippet ids by hand across three blocks. This script reads the + * reports of every shard plus coverage.json and writes one Markdown block with one row per snippet id of the manifest: + * + * - live snippets: passed, failed, skipped (with the reason), or "not run" when no shard report has a step for the id + * - compile-only snippets: type checked in the dry job, never run + * - stubs: kept for the docs URLs, never run + * + * Usage (from the SDK repository root): + * node docs-snippets/harness/snippet-summary.cjs [reportDir ...] + * + * Each reportDir holds a run.json. The default is docs-snippets/report, the folder of a local run. CI passes the three shard + * folders (docs-snippets/report/open, pam, datasync). The Markdown goes to $GITHUB_STEP_SUMMARY when it is set, and always to + * stdout. Output passes through redact() like every other file the suite writes. Never fails the job, the exit code of the + * suite decides that. + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { redact } = require('./redact.cjs'); + +const ICON = { + passed: 'passed', + failed: 'FAILED', + skipped: 'skipped', + 'not-run': 'NOT RUN', + 'compile-only': 'type checked only', + stub: 'stub, not run', +}; + +/** First line of an error, short enough for a table cell. */ +function firstLine(text) { + return String(text || '') + .split('\n')[0] + .replace(/\|/g, '\\|') + .replace(/`/g, "'") + .slice(0, 160); +} + +/** The result of one manifest entry, from the step records of all reports. Pure. */ +function resultOf(id, entry, records) { + const hit = records.filter((r) => (r.step.snippets || []).includes(id) || (entry.step && r.step.id === entry.step)); + if (hit.length === 0) { + if (entry.mode === 'compile-only') return { status: 'compile-only', reason: entry.reason }; + if (entry.mode === 'stub') return { status: 'stub', reason: entry.reason }; + return { status: 'not-run' }; + } + // A failure anywhere wins, then a skip, then pass. + const failed = hit.find((r) => r.step.status === 'failed'); + const skipped = hit.find((r) => r.step.status === 'skipped'); + const chosen = failed || skipped || hit[0]; + return { + status: chosen.step.status, + reason: chosen.step.status === 'skipped' ? chosen.step.reason : undefined, + error: chosen.step.status === 'failed' ? chosen.step.error : undefined, + stepId: chosen.step.id, + shard: chosen.shard, + page: chosen.step.page, + ms: hit.reduce((sum, r) => sum + (r.step.durationMs || 0), 0), + }; +} + +/** + * The Markdown block. Pure: `reports` is [{ shard, report }] (report = a parsed run.json), `manifest` is the `snippets` + * object of coverage.json. + */ +function renderSnippetSummary(reports, manifest) { + const records = []; + for (const { shard, report } of reports) for (const step of report.steps || []) records.push({ shard, step }); + const rows = Object.keys(manifest) + .sort((a, b) => a.localeCompare(b)) + .map((id) => ({ id, entry: manifest[id], result: resultOf(id, manifest[id], records) })); + + const count = (status) => rows.filter((r) => r.result.status === status).length; + const lines = []; + lines.push('## Docs snippets: result per snippet id', ''); + const shards = reports.map((r) => r.shard).join(', ') || 'none'; + lines.push(`Shards with a report: ${shards}. Snippet ids in the manifest: ${rows.length}.`, ''); + lines.push('| Passed | Failed | Skipped | Not run | Type checked only | Stubs |', '| ---: | ---: | ---: | ---: | ---: | ---: |'); + lines.push( + `| ${count('passed')} | ${count('failed')} | ${count('skipped')} | ${count('not-run')} | ${count('compile-only')} | ${count('stub')} |`, + ); + + const failed = rows.filter((r) => r.result.status === 'failed'); + if (failed.length) { + lines.push('', `### Failed (${failed.length})`, '', '| Snippet id | Page | Shard | Error |', '| :-- | :-- | :-- | :-- |'); + for (const r of failed) + lines.push(`| \`${r.id}\` | ${r.result.page || '-'} | ${r.result.shard} | ${firstLine(r.result.error)} |`); + } + const notRun = rows.filter((r) => r.result.status === 'not-run'); + if (notRun.length) { + lines.push( + '', + `### Not run (${notRun.length})`, + '', + 'Live snippets with no step in any shard report. Usually a shard that crashed or was cancelled before it got to them.', + '', + notRun.map((r) => `\`${r.id}\``).join(', '), + ); + } + const skipped = rows.filter((r) => r.result.status === 'skipped'); + if (skipped.length) { + lines.push('', `### Skipped (${skipped.length})`, '', '| Snippet id | Reason |', '| :-- | :-- |'); + for (const r of skipped) lines.push(`| \`${r.id}\` | \`${r.result.reason || '(none)'}\` |`); + } + + lines.push('', '### All snippet ids', '', '| Snippet id | Result | Where | Time |', '| :-- | :-- | :-- | ---: |'); + for (const r of rows) { + const { status, reason, page, shard, ms } = r.result; + const where = status === 'compile-only' || status === 'stub' ? r.entry.file || '-' : `${page || '-'} (${shard || '-'})`; + const note = status === 'compile-only' || status === 'stub' ? ` (${firstLine(reason)})` : ''; + lines.push(`| \`${r.id}\` | ${ICON[status]}${note} | ${where} | ${ms === undefined ? '-' : `${(ms / 1000).toFixed(1)} s`} |`); + } + return `${lines.join('\n')}\n`; +} + +/** Reads run.json from each directory. A missing file is skipped, so a cancelled shard still gets a summary. */ +function loadReports(dirs) { + const reports = []; + for (const dir of dirs) { + const file = path.join(dir, 'run.json'); + if (!fs.existsSync(file)) continue; + const report = JSON.parse(fs.readFileSync(file, 'utf8')); + const shard = (report.selection && report.selection.shard) || path.basename(dir); + reports.push({ shard, report }); + } + return reports; +} + +function main(argv) { + const root = path.resolve(__dirname, '..'); + const dirs = argv.length ? argv : [path.join(root, 'report')]; + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'coverage.json'), 'utf8')).snippets; + const md = redact(renderSnippetSummary(loadReports(dirs), manifest)); + process.stdout.write(md); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); +} + +if (require.main === module) main(process.argv.slice(2)); + +module.exports = { renderSnippetSummary, loadReports, resultOf }; diff --git a/docs-snippets/harness/stage.ts b/docs-snippets/harness/stage.ts new file mode 100644 index 000000000..81c6c67c5 --- /dev/null +++ b/docs-snippets/harness/stage.ts @@ -0,0 +1,385 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; +import { allRewritableNames, isPlaceholder, isRegisteredName, placeholders, suffixName } from '../fixtures/names.js'; +import { isVisibleLine, scanMarkers, type SnippetRegion } from './markers.js'; + +/** + * Staging rewrite. + * + * Copies the suite into `docs-snippets/.run//` and rewrites registered literals inside the visible part of snippet + * regions. + * Rewrites stay on the same line, so stack traces still point at the right line of the source file. + * Hidden lines and everything outside the markers are left alone: authors use `world.n()` there. + * + * Why a staged copy at all. The docs embed the source files verbatim (EmbeddedCode fetches them from the repository), so + * the source must show `my_channel`, not `my_channel-rk3x9q`. A live run needs unique names though, because two people or + * two CI jobs share the keysets. The copy under `.run//` is what Mocha executes. The original is never modified. + * + * Why the TypeScript compiler and not a regex. Only string literals in value position may change. A property name + * (`{ channel: ... }`), an import path, a type literal or an option like `flags: 'a'` must stay. The AST tells them apart. + */ + +/** Directories whose files may contain snippets and are rewritten. */ +export const PAGE_DIRS = ['api-reference', 'shared', 'getting-started', 'migration-guides']; +// Everything the staged run imports. `quickstarts/` is absent on purpose: those sources are type checked only +// (tsconfig.browser.json) or not at all, because this repository cannot run a browser, React or React Native. +const COPY_DIRS = ['harness', 'fixtures', ...PAGE_DIRS]; +const COPY_FILES = ['run.ts', 'package.json', 'tsconfig.live.json', 'tsconfig.run.json']; + +/** Snippet ids whose `snippet.end` line is not at a statement position, so the ran marker went to the next statement. */ +export interface RanFallback { + id: string; + file: string; + line: number; +} + +export interface Rewrite { + file: string; + line: number; + from: string; + to: string; + kind: 'name' | 'placeholder'; +} + +export interface StageResult { + runDir: string; + runId: string; + rewrites: Rewrite[]; + filesCopied: number; + /** Registered names that no snippet region uses (they are legal, but worth a look). */ + unusedNames: string[]; + problems: string[]; + /** Ids whose `__snippetRan` call needed the AST fallback. */ + ranFallbacks: RanFallback[]; + /** Number of `snippet.end` lines that got a `__snippetRan` call. */ + ranMarkers: number; + /** The mutated id, when DOCS_SNIPPETS_MUTATE was applied. */ + mutated?: string; +} + +export interface StageOptions { + /** DOCS_SNIPPETS_MUTATE: insert a throw right after this snippet's start marker in the staged copy. */ + mutate?: string; +} + +/** Recursive copy that skips the directories a staged run must not contain: dependencies, other runs, reports. */ +function copyDir(from: string, to: string, onFile: () => void): void { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.run' || entry.name === 'report') continue; + const src = path.join(from, entry.name); + const dst = path.join(to, entry.name); + if (entry.isDirectory()) copyDir(src, dst, onFile); + else { + fs.copyFileSync(src, dst); + onFile(); + } + } +} + +/** Rewrites one source text. Exposed for tests and the dry run. */ +export function rewriteSource(text: string, file: string, runId: string): { text: string; rewrites: Rewrite[] } { + const { regions } = scanMarkers(text); + // No snippets, nothing to rewrite (shared helpers, fixtures). Skips parsing the file. + if (regions.length === 0) return { text, rewrites: [] }; + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const edits: Array<{ start: number; end: number; replacement: string; rewrite: Rewrite }> = []; + + const visit = (node: ts.Node): void => { + if (ts.isStringLiteral(node) && !isStructuralLiteral(node)) { + const start = node.getStart(source); + const { line: zeroLine } = source.getLineAndCharacterOfPosition(start); + const line = zeroLine + 1; + // Only literals on visible lines of a snippet. A multi-line template string is skipped because a rewrite must keep + // the line count identical (stack traces and the compare tool rely on that). + const region = regions.find((r) => isVisibleLine(r, line)); + if (region && !text.slice(start, node.end).includes('\n')) { + const quote = text[start]; + const value = node.text; + // Placeholder first: its literal is not a resource name, and it becomes a call, not a suffixed string. + if (isPlaceholder(value)) { + const replacement = `world.ph(${quote}${placeholders[value]}${quote})`; + edits.push({ start, end: node.end, replacement, rewrite: { file, line, from: value, to: replacement, kind: 'placeholder' } }); + } else if (isRegisteredName(value)) { + const suffixed = suffixName(value, runId); + edits.push({ + start, + end: node.end, + replacement: `${quote}${suffixed}${quote}`, + rewrite: { file, line, from: value, to: suffixed, kind: 'name' }, + }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + + // Apply edits from the end of the file backwards, so earlier offsets stay valid while the text changes length. + let out = text; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end); + } + return { text: out, rewrites: edits.map((e) => e.rewrite).sort((a, b) => a.line - b.line) }; +} + +// Option keys whose string values are never resource names, although the value may equal a registered name. +const NON_RESOURCE_KEYS = new Set(['flags', 'encoding']); + +/** Property names, import paths and similar positions are never resource values. */ +function isStructuralLiteral(node: ts.StringLiteral): boolean { + const parent = node.parent; + if (!parent) return false; + if (ts.isPropertyAssignment(parent) && parent.name === node) return true; + // Values of options that are never resource names, for example `fs.createWriteStream(path, { flags: 'a' })`. `a` is also a registered channel. + if (ts.isPropertyAssignment(parent) && parent.initializer === node && ts.isIdentifier(parent.name) && NON_RESOURCE_KEYS.has(parent.name.text)) return true; + if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) return true; + if (ts.isLiteralTypeNode(parent)) return true; + // The key inside an already staged `world.ph('key')` call is not a snippet literal (a key may equal its literal). + if (ts.isCallExpression(parent) && parent.expression.getText().endsWith('world.ph')) return true; + if (ts.isElementAccessExpression(parent) && parent.argumentExpression === node) return false; + return false; +} + +/** Staging directories older than this are removed by the next stage. */ +const STALE_STAGE_MS = 24 * 60 * 60 * 1000; + +/** + * Stages into `.run//`, so two runs (a live run and a dry run, two agents, two CI jobs on one disk) never share + * or delete each other's copy. Only this run's directory is replaced. Directories untouched for a day are pruned. + */ +export function stage(appDir: string, runId: string, options: StageOptions = {}): StageResult { + const root = path.join(appDir, '.run'); + const runDir = path.join(root, runId); + fs.mkdirSync(root, { recursive: true }); + pruneStale(root, runId); + fs.rmSync(runDir, { recursive: true, force: true }); + fs.mkdirSync(runDir, { recursive: true }); + + let filesCopied = 0; + const bump = (): void => { + filesCopied += 1; + }; + for (const dir of COPY_DIRS) { + const from = path.join(appDir, dir); + if (fs.existsSync(from)) copyDir(from, path.join(runDir, dir), bump); + } + for (const file of COPY_FILES) { + const from = path.join(appDir, file); + if (fs.existsSync(from)) { + fs.copyFileSync(from, path.join(runDir, file)); + bump(); + } + } + + const rewrites: Rewrite[] = []; + const problems: string[] = []; + for (const dir of PAGE_DIRS) { + const root = path.join(runDir, dir); + if (!fs.existsSync(root)) continue; + for (const file of walk(root).filter((f) => f.endsWith('.ts') || f.endsWith('.tsx'))) { + const relative = path.relative(runDir, file); + const original = fs.readFileSync(file, 'utf8'); + const result = rewriteSource(original, relative, runId); + if (result.rewrites.length === 0) continue; + // Safety net for the rewrite: the staged file must still parse and keep the same number of lines, otherwise a + // failing step would point at a different line than the source file the author edits. + if (result.text.split('\n').length !== original.split('\n').length) { + problems.push(`${relative}: rewrite changed the line count`); + } + const diagnostics = ts.transpileModule(result.text, { + reportDiagnostics: true, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).diagnostics; + for (const d of diagnostics ?? []) { + problems.push(`${relative}: staged file does not parse: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`); + } + fs.writeFileSync(file, result.text); + rewrites.push(...result.rewrites); + } + } + + // Second pass as a self-check. Running the rewrite on already staged text must find nothing: a hit means a registered + // literal escaped the first pass (for example inside a construct the visitor skipped). + // Verify: no registered literal is left unrewritten inside a visible snippet region of the staged tree. + for (const dir of PAGE_DIRS) { + const root = path.join(runDir, dir); + if (!fs.existsSync(root)) continue; + for (const file of walk(root).filter((f) => f.endsWith('.ts'))) { + const relative = path.relative(runDir, file); + const text = fs.readFileSync(file, 'utf8'); + const leftover = rewriteSource(text, relative, runId).rewrites; + // After one pass, rewriting again finds only literals that were not suffixed (the suffixed form is not registered). + for (const l of leftover) problems.push(`${relative}:${l.line}: "${l.from}" is still unrewritten after staging`); + } + } + + // Names registered in fixtures/names.ts that no snippet uses. Legal (harness steps may use them), shown so the registry stays tidy. + const used = new Set(rewrites.filter((r) => r.kind === 'name').map((r) => r.from)); + const unusedNames = allRewritableNames().filter((name) => !used.has(name)); + + // Honesty markers, after all marker parsing and rewriting: every snippet region inside a step() body records that its last + // line ran (`__snippetRan('')` on the `snippet.end` line), and DOCS_SNIPPETS_MUTATE breaks one snippet on purpose. + // Both edits stay on their line, so line numbers still match the source. + const ranFallbacks: RanFallback[] = []; + let ranMarkers = 0; + let mutated: string | undefined; + for (const dir of PAGE_DIRS) { + const root = path.join(runDir, dir); + if (!fs.existsSync(root)) continue; + for (const file of walk(root).filter((f) => f.endsWith('.ts'))) { + const relative = path.relative(runDir, file); + const text = fs.readFileSync(file, 'utf8'); + const result = instrumentSource(text, relative, options.mutate); + if (result.text === text) continue; + ranMarkers += result.marked; + ranFallbacks.push(...result.fallbacks.map((f) => ({ ...f, file: relative }))); + if (result.mutated) mutated = result.mutated; + problems.push(...result.problems.map((p) => `${relative}: ${p}`)); + fs.writeFileSync(file, result.text); + } + } + if (options.mutate && !mutated) problems.push(`DOCS_SNIPPETS_MUTATE=${options.mutate}: no snippet with that id inside a step() body`); + + // `pubnub` for child processes and scratch projects (the module format check, the Node quickstart, the TypeScript project + // checks). They run plain Node or tsc below this directory, so they resolve `pubnub` the way a user project does, through + // node_modules. The link points at this repository, whose package.json `main` is lib/node/index.js. Nothing is installed. + const modules = path.join(runDir, 'node_modules'); + fs.mkdirSync(modules, { recursive: true }); + fs.symlinkSync(path.resolve(appDir, '..'), path.join(modules, 'pubnub'), 'dir'); + + // A record of every rewrite for humans debugging a live failure (`.run//stage-manifest.json`). + fs.writeFileSync( + path.join(runDir, 'stage-manifest.json'), + JSON.stringify({ runId, generatedAt: new Date().toISOString(), rewrites }, null, 2), + ); + return { runDir, runId, rewrites, filesCopied, unusedNames, problems, ranFallbacks, ranMarkers, mutated }; +} + +/** True when the text parses as a module (the staged files must stay valid TypeScript). */ +function parses(text: string): string[] { + const diagnostics = ts.transpileModule(text, { + reportDiagnostics: true, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).diagnostics; + return (diagnostics ?? []).map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' ')); +} + +/** Ids of regions that sit inside a `step()` callback (not check, not stub, not import-only). */ +function stepRegions(text: string, file: string): SnippetRegion[] { + const { regions } = scanMarkers(text); + if (regions.length === 0) return []; + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const spans: Array<[number, number]> = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'step') { + const fn = [...node.arguments].reverse().find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a)); + if (fn) spans.push([source.getLineAndCharacterOfPosition(fn.getStart(source)).line + 1, source.getLineAndCharacterOfPosition(fn.end).line + 1]); + } + ts.forEachChild(node, visit); + }; + visit(source); + return regions.filter((r) => r.explicitEnd && spans.some(([a, b]) => r.startLine >= a && r.endLine <= b)); +} + +/** + * Finds where to put a statement for a marker line that is not at a statement position: right after the innermost statement + * that contains the line, so it runs once that statement finished. Returns an offset in `text`, or undefined. + */ +function afterEnclosingStatement(text: string, file: string, line: number): number | undefined { + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const position = source.getPositionOfLineAndCharacter(line - 1, 0); + let best: ts.Statement | undefined; + const visit = (node: ts.Node): void => { + if (node.getFullStart() > position || node.end < position) return; + const parent = node.parent; + const inList = parent && (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseClause(parent) || ts.isDefaultClause(parent) || ts.isModuleBlock(parent)); + if (inList && node.getStart(source) <= position && node.end >= position) best = node as ts.Statement; + ts.forEachChild(node, visit); + }; + visit(source); + return best?.end; +} + +/** + * Adds `__snippetRan('')` to the `snippet.end` line of every region inside a step body, and with `mutate` a throw at + * the start marker of that id. Same line, so line numbers stay. When the plain edit does not parse (the marker sits inside an + * expression), the call goes after the enclosing statement instead, and the id is listed as a fallback. + */ +export function instrumentSource( + text: string, + file: string, + mutate?: string, +): { text: string; marked: number; fallbacks: Array<{ id: string; line: number }>; mutated?: string; problems: string[] } { + const regions = stepRegions(text, file); + const fallbacks: Array<{ id: string; line: number }> = []; + const problems: string[] = []; + let mutated: string | undefined; + let lines = text.split('\n'); + // From the bottom up, so earlier line numbers stay valid while lines change. + for (const region of [...regions].sort((a, b) => b.endLine - a.endLine)) { + const call = `__snippetRan('${region.id}');`; + const plain = [...lines]; + plain[region.endLine - 1] = plain[region.endLine - 1].replace(/^(\s*)/, `$1${call} `); + if (parses(plain.join('\n')).length === 0) { + lines = plain; + } else { + const current = lines.join('\n'); + const offset = afterEnclosingStatement(current, file, region.endLine); + if (offset === undefined) { + problems.push(`no statement position for __snippetRan('${region.id}') near line ${region.endLine}`); + continue; + } + const edited = `${current.slice(0, offset)} ${call}${current.slice(offset)}`; + const errors = parses(edited); + if (errors.length > 0) { + problems.push(`__snippetRan('${region.id}') does not parse after the fallback: ${errors[0]}`); + continue; + } + lines = edited.split('\n'); + fallbacks.push({ id: region.id, line: region.endLine }); + } + } + // `__snippetStart('')` on the start marker line: the honesty check counts only assertions made after the docs code began. + for (const region of [...regions].sort((x, y) => y.startLine - x.startLine)) { + const plain = [...lines]; + plain[region.startLine - 1] = plain[region.startLine - 1].replace(/^(\s*)/, `$1__snippetStart('${region.id}'); `); + if (parses(plain.join('\n')).length === 0) lines = plain; + else problems.push(`__snippetStart('${region.id}') is not at a statement position`); + } + if (mutate) { + const region = regions.find((r) => r.id === mutate); + if (region) { + const throwLine = `throw new Error('DOCS_SNIPPETS_MUTATE ${mutate}');`; + const plain = [...lines]; + plain[region.startLine - 1] = plain[region.startLine - 1].replace(/^(\s*)/, `$1${throwLine} `); + if (parses(plain.join('\n')).length === 0) { + lines = plain; + mutated = mutate; + } else { + problems.push(`DOCS_SNIPPETS_MUTATE=${mutate}: the start marker is not at a statement position`); + } + } + } + return { text: lines.join('\n'), marked: regions.length - problems.filter((p) => p.startsWith('no statement') || p.includes('after the fallback')).length, fallbacks, mutated, problems }; +} + +function pruneStale(root: string, keep: string): void { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === keep) continue; + const full = path.join(root, entry.name); + try { + if (Date.now() - fs.statSync(full).mtimeMs > STALE_STAGE_MS) fs.rmSync(full, { recursive: true, force: true }); + } catch { + // Another run removed it first. + } + } +} + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? walk(full) : [full]; + }); +} diff --git a/docs-snippets/harness/step.ts b/docs-snippets/harness/step.ts new file mode 100644 index 000000000..edc6e5637 --- /dev/null +++ b/docs-snippets/harness/step.ts @@ -0,0 +1,100 @@ +/** + * The authoring API of the suite: what a page module imports to register its steps. + * + * page(path, defaultPhase, (world) => { step(...); check(...); stub(...); }) + * + * - `step` runs one or more docs snippets (the code between `// snippet.` markers) and asserts around them. + * - `check` is a harness-only step with no markers: fixtures, effect checks, error tables, preflights. + * - `stub` is a documented skipped step. It keeps a docs page visible in the report without running anything. + * - `skipStep` lets a running step skip itself with a precise reason (see `SkipStep`). + * + * Nothing runs when these functions are called. They only record a definition in the registry (registry.ts). + * `harness/mocha-entry.ts` later turns each definition into a Mocha test, so the order of registration, the phase and + * the `needs` edges decide when and whether a step executes. + */ +import { registry, type Phase, type StepOptions } from './registry.js'; +import type { World } from './world.js'; + +export type { Phase, StepOptions } from './registry.js'; + +type Body = () => Promise | void; + +/** + * One docs snippet run (or a short sequence of snippets the docs show together). + * + * The id is the first snippet id inside the body. The marker lint checks that, checks that `snippets` equals + * the markers inside, and rejects nesting. Snippets let errors throw, so the body asserts around them. + */ +export function step(id: string, options: StepOptions, fn: Body): void; +export function step(id: string, fn: Body): void; +export function step(id: string, a: StepOptions | Body, b?: Body): void { + const options = typeof a === 'function' ? {} : a; + const fn = typeof a === 'function' ? a : (b as Body); + registry.add('step', id, options, fn); +} + +/** A harness-only step: fixtures, effect checks and error tables. It contains no snippet markers. */ +export function check(id: string, options: StepOptions, fn: Body): void; +export function check(id: string, fn: Body): void; +export function check(id: string, a: StepOptions | Body, b?: Body): void { + const options = typeof a === 'function' ? {} : a; + const fn = typeof a === 'function' ? a : (b as Body); + registry.add('check', id, options, fn); +} + +/** A documented skipped stub. Reported as skipped with its reason, never executed. */ +export function stub(id: string, reason: string, note: string): void { + // The stub note doubles as the description in the report, so a skipped stub says what it stands for. + registry.add('stub', id, { skipIf: reason, proves: note }, () => { + throw new Error(`stub ${id} must never run: ${note}`); + }); +} + +/** What a page file default exports. `run.ts` lists these, `setup` is called once per run with the shared `World`. */ +export interface PageModule { + path: string; + phase: Phase; + setup(world: World): void; +} + +/** A docs page module. `phase` is the default for its steps, a step may override it. */ +export function page(pagePath: string, phase: Phase, setup: (world: World) => void): PageModule { + return { + path: pagePath, + phase, + setup(world) { + registry.withPage(pagePath, phase, () => setup(world)); + }, + }; +} + +/** + * Thrown by a step or check to skip itself with a precise reason, for example a keyset that lacks DataSync + * classes. The runner records the step as skipped, and steps that `needs` it are skipped as `blocked-by:`. + * + * Two reason families are accepted, both already inside the allowed list: + * - `blocked-by:`: a root cause (a missing class) hides this step. Counts against `DOCS_SNIPPETS_SKIP_BUDGET`. + * - `needs-feature:`: the keyset lacks a switch that cannot be set from code (DataSync event rules live in the + * Admin Portal). It does not count against the budget. `DOCS_SNIPPETS_REQUIRE_FEATURES` makes it a failure (policy.ts). + * + * `detail` is free text for the human: it is printed and written to the report as `note`, so the reason itself can + * stay a short, matchable tag. + */ +export class SkipStep extends Error { + readonly reason: string; + readonly detail?: string; + constructor(reason: string, detail?: string) { + super(`skipped: ${reason}`); + this.name = 'SkipStep'; + this.reason = reason; + this.detail = detail; + if (!/^(blocked-by:.+|needs-feature:[a-z0-9-]+)$/.test(reason)) { + throw new Error(`SkipStep reason must be "blocked-by:" or "needs-feature:", got "${reason}"`); + } + } +} + +/** Skips the running step from inside its body. Typed `never`, so code after the call is known to be unreachable. */ +export function skipStep(reason: string, detail?: string): never { + throw new SkipStep(reason, detail); +} diff --git a/docs-snippets/harness/tsc-project.ts b/docs-snippets/harness/tsc-project.ts new file mode 100644 index 000000000..9d0611560 --- /dev/null +++ b/docs-snippets/harness/tsc-project.ts @@ -0,0 +1,48 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Compiles small TypeScript files with the real `tsc` against the installed SDK types and returns the diagnostics. + * Used by the type-check-only modules (migration guide imports). The scratch directory sits below the staged suite so + * that `pubnub` and the type packages resolve from the suite's `node_modules`. + */ +const TSC = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('typescript'))), 'tsc.js'); + +export interface TscResult { + ok: boolean; + /** `tsc` output, one diagnostic per line. */ + output: string; +} + +export function tscProject(parent: string, files: Record, compilerOptions: Record = {}): TscResult { + const dir = fs.mkdtempSync(path.join(parent, '.tsc-')); + try { + for (const [name, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); + fs.writeFileSync(path.join(dir, name), content); + } + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + noEmit: true, + esModuleInterop: true, + skipLibCheck: true, + types: [], + ...compilerOptions, + }, + include: Object.keys(files).filter((f) => /\.tsx?$/.test(f)), + }), + ); + const run = spawnSync(process.execPath, [TSC, '-p', dir], { encoding: 'utf8' }); + return { ok: run.status === 0, output: `${run.stdout}${run.stderr}`.trim() }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/docs-snippets/harness/wait.ts b/docs-snippets/harness/wait.ts new file mode 100644 index 000000000..c86a74788 --- /dev/null +++ b/docs-snippets/harness/wait.ts @@ -0,0 +1,397 @@ +/** + * Waiting primitives. The rule of the suite is "no blind retries and no fixed sleeps": every wait either polls a read until + * an assertion holds (`eventually`) or listens for the exact event the action should cause. + * + * - `eventually` poll a read (hereNow, history, App Context filter) that is eventually consistent on the service. + * - `expectEvent` register a listener BEFORE the action, resolve on the first matching event. + * - `connected` resolve when a subscribe is really active. Subscribing is asynchronous, a publish sent before that + * point is not delivered to the new subscriber, which is the classic race in pub/sub tests. + * - `watch` subscribe once and record everything, so the assertion can come after the publish without racing. + * - `expectStatus` resolve on a status category such as `PNConnectedCategory`. + * + * Every helper rejects with a message that says what was awaited and what was seen, so a timeout reads as a diagnosis. + */ +import assert from './assert.js'; +import { recorder, type AssertionRecord } from './recorder.js'; +import type PubNub from 'pubnub'; +import { redact } from './redact.cjs'; + +export interface EventuallyOptions { + /** Milliseconds. Default 15000. Presence reads use 45000. */ + timeout?: number; + /** Milliseconds between attempts. Default 500. */ + interval?: number; + label?: string; +} + +// Presence is the slowest eventual state: join and timeout events lag the heartbeat (see World.presenceOptions). +export const PRESENCE_TIMEOUT = 45_000; + +/** + * Polls a read until the callback stops throwing, then returns its value. No blind retries: on timeout the + * error carries the last assertion message so the failure explains itself. + */ +export async function eventually(fn: () => Promise | T, options: EventuallyOptions = {}): Promise { + const timeout = options.timeout ?? 15_000; + const interval = options.interval ?? 500; + const deadline = Date.now() + timeout; + const name = options.label ? `eventually: ${options.label}` : 'eventually'; + let last: unknown; + let attempts = 0; + for (;;) { + attempts += 1; + // Assertions inside the callback record into a private buffer. A failed attempt is expected and must not show up in the + // report as a failed assertion, only the attempt that passed is kept (see recorder.ts). + const buffer: AssertionRecord[] = []; + try { + const value = await recorder.attempt(buffer, fn); + recorder.commit(buffer); + recorder.record({ kind: 'eventually', label: `${name} held${attempts > 1 ? ` after ${attempts} attempts` : ''}`, ok: true }); + return value; + } catch (error) { + last = error; + } + if (Date.now() + interval > deadline) break; + await sleep(interval); + } + const detail = last instanceof Error ? last.message : String(last); + recorder.record({ kind: 'eventually', label: `${name} did not hold within ${timeout}ms (${attempts} attempts)`, ok: false, actual: redact(detail).slice(0, 200) }); + const error = new Error(`eventually${options.label ? ` (${options.label})` : ''} timed out after ${timeout}ms. Last: ${detail}`); + (error as Error & { cause?: unknown }).cause = last; + throw error; +} + +/** + * Removes a listener after the current dispatch. Removing it inside its own callback mutates the SDK's listener + * array while it iterates, which makes the SDK skip the next listener (the first live rerun lost a snippet's + * status listener this way). + */ +function detach(target: { removeListener(listener: PubNub.Listener): void }, listener: PubNub.Listener): void { + setImmediate(() => target.removeListener(listener)); +} + +/** Anything with the listener API: the client, a subscription or a subscription set. */ +export interface ListenerTarget { + addListener(listener: PubNub.Listener): void; + removeListener(listener: PubNub.Listener): void; +} + +/** Listener callbacks the helpers can wait for. `status` has its own helpers (`connected`, `expectStatus`). */ +export type EventKind = 'message' | 'signal' | 'presence' | 'messageAction' | 'file' | 'objects'; + +type EventOf = Parameters>[0]; + +export interface ExpectOptions { + timeout?: number; +} + +/** + * Registers a listener now and resolves on the first event of `kind` that satisfies `predicate`. Call it + * before the action that causes the event. Rejects on timeout. + */ +export function expectEvent( + target: ListenerTarget, + kind: K, + predicate: (event: EventOf) => boolean = () => true, + options: ExpectOptions = {}, +): Promise> { + const timeout = options.timeout ?? 15_000; + return new Promise>((resolve, reject) => { + const listener: PubNub.Listener = {}; + const timer = setTimeout(() => { + target.removeListener(listener); + reject(new Error(`no "${kind}" event matched within ${timeout}ms`)); + }, timeout); + // The step awaits this promise, and Mocha's own test timer keeps the process alive meanwhile. unref() only means an + // abandoned wait cannot hold the process open after the run. + timer.unref(); + (listener as Record)[kind] = (event: EventOf) => { + // A predicate that throws (reading a field of an event of another shape) means "not this one", not a test failure. + let matched = false; + try { + matched = predicate(event); + } catch { + matched = false; + } + if (!matched) return; + clearTimeout(timer); + detach(target, listener); + recorder.record({ kind: 'event', label: `received a "${kind}" event that matched`, ok: true }); + resolve(event); + }; + target.addListener(listener); + }); +} + +// Statuses that mean a subscribe loop is up. A re-subscribe reports `SubscriptionChanged`, a recovered network `Reconnected`. +const CONNECT_CATEGORIES = new Set(['PNConnectedCategory', 'PNSubscriptionChangedCategory', 'PNReconnectedCategory']); + +/** + * Resolves when the client reports a connected or subscription-changed status that covers every channel and + * group. Register it BEFORE the call that subscribes (statuses are not replayed). Affected channels + * accumulate, so several subscribes in one tick that coalesce into one status still resolve. + */ +export function connected( + client: Pick, + targets: { channels?: string[]; channelGroups?: string[] }, + options: ExpectOptions = {}, +): Promise { + const timeout = options.timeout ?? 15_000; + const needChannels = new Set(targets.channels ?? []); + const needGroups = new Set(targets.channelGroups ?? []); + return new Promise((resolve, reject) => { + const seenChannels = new Set(); + const seenGroups = new Set(); + const listener: PubNub.Listener = { + status: (status) => { + if (!CONNECT_CATEGORIES.has(String(status.category))) return; + const event = status as PubNub.StatusEvent; + // A status without lists means the whole subscription is up. + event.affectedChannels?.forEach((c) => seenChannels.add(c)); + event.subscribedChannels?.forEach((c) => seenChannels.add(c)); + event.affectedChannelGroups?.forEach((g) => seenGroups.add(g)); + const done = [...needChannels].every((c) => seenChannels.has(c)) && [...needGroups].every((g) => seenGroups.has(g)); + if (done) { + clearTimeout(timer); + detach(client, listener); + recorder.record({ kind: 'connected', label: `subscription connected (${[...needChannels, ...needGroups].length} channel or group name(s))`, ok: true }); + resolve(); + } + }, + }; + const timer = setTimeout(() => { + client.removeListener(listener); + reject( + new Error( + `not connected within ${timeout}ms. Wanted channels [${[...needChannels]}] groups [${[...needGroups]}], ` + + `saw channels [${[...seenChannels]}] groups [${[...seenGroups]}]`, + ), + ); + }, timeout); + // The step awaits this promise, and Mocha's own test timer keeps the process alive meanwhile. unref() only means an + // abandoned wait cannot hold the process open after the run. + timer.unref(); + client.addListener(listener); + }); +} + +/** One event a `Watch` saw, tagged with the listener it arrived on. */ +export interface RecordedEvent { + kind: EventKind; + event: unknown; +} + +/** + * A subscription that records every event it receives, so a step can subscribe once and assert later without + * racing the publish. Created with `watch()`. + */ +export class Watch { + readonly events: RecordedEvent[] = []; + // Callers blocked in `next()`. Each is woken as soon as an event arrives, instead of polling on a timer only. + private waiters: Array<() => void> = []; + + constructor( + private readonly set: ReturnType, + private readonly client: PubNub, + ) { + const record = (kind: EventKind) => (event: unknown) => { + this.events.push({ kind, event }); + this.waiters.splice(0).forEach((wake) => wake()); + }; + set.addListener({ + message: record('message'), + signal: record('signal'), + presence: record('presence'), + messageAction: record('messageAction'), + file: record('file'), + objects: record('objects'), + }); + } + + /** Resolves with the first recorded event of `kind` matching `predicate`, waiting if needed. */ + async next( + kind: K, + predicate: (event: EventOf) => boolean = () => true, + options: ExpectOptions = {}, + ): Promise> { + const timeout = options.timeout ?? 15_000; + const deadline = Date.now() + timeout; + for (;;) { + for (const recorded of this.events) { + if (recorded.kind !== kind) continue; + let ok = false; + try { + ok = predicate(recorded.event as EventOf); + } catch { + ok = false; + } + if (ok) { + recorder.record({ kind: 'event', label: `watched client received a "${kind}" event that matched`, ok: true }); + return recorded.event as EventOf; + } + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + assert.fail(`no "${kind}" event matched within ${timeout}ms. Recorded: ${this.events.map((e) => e.kind).join(',') || 'none'}`); + } + // Sleep until the next event wakes us. The 250 ms cap is a safety net, not a poll interval. + await new Promise((resolve) => { + const timer = setTimeout(resolve, Math.min(remaining, 250)); + this.waiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + } + + /** Unsubscribes and drops the listeners. Call it when the step is done so the observer stops receiving. */ + stop(): void { + this.set.unsubscribe(); + this.set.removeAllListeners(); + void this.client; + } +} + +export interface WatchOptions { + channelGroups?: string[]; + presence?: boolean; + timeout?: number; +} + +/** + * Subscribes `client` to channels (and groups), waits until connected and returns a recording `Watch`. + * The channels must already carry the run suffix (use `world.n()`). + * + * Pass a client that has not subscribed before, for example `world.fresh('observer')`. A reused client can + * still report the status of an earlier unsubscribe for the same channel, which would resolve `connected` + * before the new subscription is active. + */ +export async function watch(client: PubNub, channels: string[], options: WatchOptions = {}): Promise { + const set = client.subscriptionSet({ + channels, + channelGroups: options.channelGroups, + subscriptionOptions: options.presence ? { receivePresenceEvents: true } : undefined, + }); + // Order matters: register the connected listener first, then subscribe. Statuses are not replayed, so a listener added + // after `subscribe()` could miss the very status it waits for. + const ready = connected(client, { channels, channelGroups: options.channelGroups }, { timeout: options.timeout }); + const handle = new Watch(set, client); + set.subscribe(); + await ready; + return handle; +} + +/** + * Resolves on the first status event whose category is one of `categories`. Register it before the action. + */ +export function expectStatus( + client: Pick, + categories: string[], + options: ExpectOptions = {}, +): Promise { + const timeout = options.timeout ?? 15_000; + return new Promise((resolve, reject) => { + const listener: PubNub.Listener = { + status: (status) => { + const category = String(status.category); + if (!categories.includes(category)) return; + clearTimeout(timer); + detach(client, listener); + recorder.record({ kind: 'status', label: `status ${category} received`, ok: true }); + resolve(category); + }, + }; + const timer = setTimeout(() => { + client.removeListener(listener); + reject(new Error(`no status in [${categories.join(', ')}] within ${timeout}ms`)); + }, timeout); + // The step awaits this promise, and Mocha's own test timer keeps the process alive meanwhile. unref() only means an + // abandoned wait cannot hold the process open after the run. + timer.unref(); + client.addListener(listener); + }); +} + +export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Tallies the events a step's recording listener saw, by kind, with the time of the first one. A step that waits for + * "every event kind" uses it so a timeout names the kinds that arrived (with counts) and the kinds still missing, + * instead of reporting only the one assertion that failed last. Times are milliseconds since the log was created. + * + * `match` narrows a kind to the events the step actually triggered (for example the join of the second client, not any + * presence event on a busy channel). An event that does not match is kept in `of()` but does not count as arrived. + */ +export class KindLog { + private readonly events = new Map(); + private readonly first = new Map(); + private readonly started = Date.now(); + + constructor( + readonly kinds: readonly string[], + private readonly match: Record boolean> = {}, + ) { + kinds.forEach((kind) => this.events.set(kind, [])); + } + + /** Listener callback for one kind: `message: log.on('message')`. */ + on(kind: string): (event: E) => void { + return (event) => { + this.events.get(kind)!.push(event); + if (this.matches(kind, event) && !this.first.has(kind)) this.first.set(kind, Date.now() - this.started); + }; + } + + /** The events of one kind, in arrival order (all of them, matching or not). */ + of(kind: string): E[] { + return this.events.get(kind) ?? []; + } + + private matches(kind: string, event: E): boolean { + const predicate = this.match[kind]; + if (!predicate) return true; + try { + return predicate(event); + } catch { + return false; + } + } + + /** The events of one kind that count as the awaited one. */ + matching(kind: string): E[] { + return this.of(kind).filter((event) => this.matches(kind, event)); + } + + /** "arrived: message x1 (+120ms), presence x2 (+800ms). missing: signal, objects. other events: presence x3". */ + summary(required: readonly string[] = this.kinds): string { + const arrived = required.filter((k) => this.matching(k).length > 0).map((k) => `${k} x${this.matching(k).length} (+${this.first.get(k)}ms)`); + const missing = required.filter((k) => this.matching(k).length === 0); + const other = required.filter((k) => this.of(k).length > this.matching(k).length).map((k) => `${k} x${this.of(k).length - this.matching(k).length}`); + return `arrived: ${arrived.join(', ') || 'none'}. missing: ${missing.join(', ') || 'none'}${other.length ? `. other events: ${other.join(', ')}` : ''}`; + } + + /** Throws (for `eventually`) while any required kind has no matching event. The message carries the full summary. */ + assertArrived(required: readonly string[] = this.kinds): void { + if (required.some((k) => this.matching(k).length === 0)) throw new Error(this.summary(required)); + } +} + +/** + * For conditions that are not event kinds (a console line per handler, say). Throws one error that lists every + * condition that does not hold, so a timeout names all missing ones and not only the first. + */ +export function assertAll(conditions: Record): void { + const missing = Object.entries(conditions).filter(([, ok]) => !ok).map(([name]) => name); + if (missing.length > 0) { + const held = Object.entries(conditions).filter(([, ok]) => ok).map(([name]) => name); + throw new Error(`missing: ${missing.join(', ')}. held: ${held.join(', ') || 'none'}`); + } +} + +/** + * How long a step waits for a presence event it caused. The keyset announces a join or leave after the 2 s debounce when + * the channel holds at most 20 occupants (announce max), and falls back to one summary per 10 s interval above that. + * 30 s covers debounce, one interval and a long-poll cycle with margin. + */ +export const PRESENCE_EVENT_TIMEOUT = 30_000; diff --git a/docs-snippets/harness/world.ts b/docs-snippets/harness/world.ts new file mode 100644 index 000000000..425dce356 --- /dev/null +++ b/docs-snippets/harness/world.ts @@ -0,0 +1,403 @@ +/** + * The `world` object every page module receives. It is the harness side of the test: run id, name suffixing, + * placeholders, the cast of clients, cleanup tracking, spies and console capture. + * + * Architecture notes: + * - One `World` per run. `mocha-entry.ts` builds it from the environment, registers all pages with it, and disposes + * it in the root `after` hook (plus the signal handler for a killed run). + * - Clients are created lazily on first use and cached by role. A run that only has the open keyset never + * builds `server`, `alice` or `bob`, so it never needs those keys. + * - Every client is tracked and destroyed at the end. A leaked subscribed client can keep the Node process alive and + * leaves presence entries on the keyset. + * - Names go through `n()` (the run suffix, see fixtures/names.ts). Inside snippets the stage rewrite applies the suffix, + * outside snippets the step author calls `world.n()`. + */ +import type { ChildProcess } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from './assert.js'; +import PubNub from 'pubnub'; +import { suffixName } from '../fixtures/names.js'; +import { randomRunId, type Env, type LogLevelName, type Role, type SecuredKeyset } from './env.js'; +import { LocalProxy } from './proxy.js'; +import { addSecrets } from './redact.cjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +/** + * The Sneaker Drop cast. `server` is the staff back office, `shopper`/`friend` are plain open-keyset users, `alice`/`bob` hold + * tokens. `server`, `alice` and `bob` live on a secured keyset: the one of the running step's role (`pam` or `datasync`). + */ +export type ClientRole = 'server' | 'alice' | 'bob' | 'shopper' | 'friend' | 'observer'; + +// The env var holds a name (`DOCS_SNIPPETS_LOG_LEVEL=debug`), the SDK wants its enum value. +const LOG_LEVELS: Record = { + trace: PubNub.LogLevel.Trace, + debug: PubNub.LogLevel.Debug, + info: PubNub.LogLevel.Info, + warn: PubNub.LogLevel.Warn, + error: PubNub.LogLevel.Error, + none: PubNub.LogLevel.None, +}; + +/** What `world.spy()` returns: every call's arguments and result, plus a way to undo the wrap. */ +export interface SpyHandle { + calls: unknown[][]; + results: R[]; + restore(): void; +} + +export interface ConsoleCapture { + lines: string[]; + restore(): void; +} + +/** + * Everything a step needs: run id, name suffixing, placeholders, a multi-client factory and tracked cleanup. + * + * Clients are created lazily, so a run that only touches the open keyset never needs the secured one. + * The secret key only ever reaches the `server` client and the snippet that shows server configuration. + */ +export class World { + readonly runId: string; + readonly env: Env; + /** Directory of fixture files. Steps that read relative paths change into it. */ + readonly fixtures = path.resolve(HERE, '..', 'fixtures'); + readonly proxy = new LocalProxy(); + private readonly tracked = new Set(); + private readonly children = new Set(); + private readonly placeholderValues = new Map(); + private readonly cache = new Map(); + /** + * Role of the step that is running, set by mocha-entry.ts before each step. Snippets name the secured keyset `secured` + * (hidden lines such as `...world.live.secured` are docs text and stay unchanged), and this decides which keyset that is. + */ + private role_: Role = 'open'; + private restorers: Array<() => void> = []; + private counter = 0; + + constructor(env: Env, runId?: string) { + this.env = env; + this.runId = runId ?? env.runId ?? randomRunId(); + } + + /** Sets the role of the running step. Called by the Mocha binding, never by a step. */ + useRole(role: Role): void { + this.role_ = role; + } + + get role(): Role { + return this.role_; + } + + /** The secured role of the running step. A step of role `open` that touches a secured keyset is a wiring error. */ + get securedRole(): 'pam' | 'datasync' { + if (this.role_ === 'open') { + throw new Error("this step runs as role open but used a secured keyset (world.server, alice, bob or live.secured). Give it keyset: 'pam' or 'datasync'."); + } + return this.role_; + } + + private securedKeys(): SecuredKeyset { + const role = this.securedRole; + const keys = this.env[role]; + if (!keys) throw new Error(`${role} keyset is not configured (${role === 'pam' ? 'PAM_*' : 'DS_*'})`); + return keys; + } + + /** + * Spread into snippet constructors through hidden lines: keys and log level only, never identity. + * The docs show `publishKey: 'YOUR_PUBLISH_KEY'`. The hidden `...world.live.open` after it overrides the placeholder with + * the real key, so the page text stays generic and the run is live. Each property is a getter so that reading a keyset + * that is not configured throws a clear message at the moment of use, not at startup. + * Typed as Partial so the spread after the visible placeholder keys compiles (TS2783 otherwise). */ + get live(): { + open: Partial<{ publishKey: string; subscribeKey: string; logLevel: PubNub.LogLevel }>; + /** Keys only. Use it where the snippet sets `logLevel` itself, because `open` carries the run's log level. */ + openKeys: Partial<{ publishKey: string; subscribeKey: string }>; + openSubscribeOnly: Partial<{ subscribeKey: string; logLevel: PubNub.LogLevel }>; + secured: Partial<{ publishKey: string; subscribeKey: string; secretKey: string; logLevel: PubNub.LogLevel }>; + } { + const logLevel = LOG_LEVELS[this.env.logLevel]; + const open = this.env.open; + const securedKeys = (): SecuredKeyset => this.securedKeys(); + return { + get openKeys() { + if (!open) throw new Error('open keyset is not configured (SUBSCRIBE_KEY, PUBLISH_KEY)'); + return { ...open }; + }, + get open() { + if (!open) throw new Error('open keyset is not configured (SUBSCRIBE_KEY, PUBLISH_KEY)'); + return { ...open, logLevel }; + }, + get openSubscribeOnly() { + if (!open) throw new Error('open keyset is not configured (SUBSCRIBE_KEY, PUBLISH_KEY)'); + return { subscribeKey: open.subscribeKey, logLevel }; + }, + get secured() { + return { ...securedKeys(), logLevel }; + }, + }; + } + + /** Spread into a configuration to point a client at a port where nothing listens (port 1 is reserved, connect is refused at once). Every request fails fast with a network error. */ + get unreachable(): { origin: string; ssl: false } { + return { origin: '127.0.0.1:1', ssl: false }; + } + + /** Applies the run suffix to a name. Use it outside snippet markers, where the stage rewrite does not apply. */ + n(name: string): string { + return suffixName(name, this.runId); + } + + // ---- placeholders --- + // A placeholder is a docs literal such as 'replace_with_message_timetoken' that only the run can fill. The stage rewrite + // turns it into world.ph('messageTimetoken'), and an earlier step supplies the value with world.setPh(). Reading a key + // nobody set throws, which points at the step order instead of sending the service a bogus timetoken.--------------------------------------------------------------------------------------- + + /** Live value for a placeholder literal. Staged snippets call this, see `fixtures/names.ts`. */ + ph(key: string): string { + const value = this.placeholderValues.get(key); + if (value === undefined) { + throw new Error(`placeholder "${key}" has no value yet. A step must call world.setPh('${key}', ...) first.`); + } + return value; + } + + setPh(key: string, value: string): void { + this.placeholderValues.set(key, value); + } + + // ---- values out of a try block -------------------------------------------------------------------------------- + // Some pages show a call inside `try { ... } catch (status) { console.log(status); }`. The `const` the call returns + // is block scoped, so a hidden `world.keep('name', value)` as the last line of the try hands it to the step's assertions, + // which read it with `world.take('name')`. If the try threw, the catch only logged and keep never ran: take then fails + // the step, so a swallowed error can never pass. + + private readonly keptValues = new Map(); + + /** Stores a value from inside a snippet's try block (hidden line). */ + keep(name: string, value: unknown): void { + this.keptValues.set(name, value); + } + + /** Reads and removes a value stored by `keep`. Throws when the snippet's try block did not reach `keep`. */ + take(name: string): T { + if (!this.keptValues.has(name)) { + throw new Error(`the snippet did not reach world.keep('${name}'): its try block threw and the catch only logged it (see the log above)`); + } + const value = this.keptValues.get(name) as T; + this.keptValues.delete(name); + return value; + } + + // ---- clients ------------------------------------------------------------------------------------------------ + + /** Registers a client for `dispose()`. Use it for clients a step builds by hand (for example the docs `new PubNub(...)`). */ + track(client: T): T { + this.tracked.add(client); + return client; + } + + /** Registers a child process. `dispose()` and the signal handlers kill it, so a killed run leaves no subscribed process behind. */ + trackChild(child: T): T { + this.children.add(child); + return child; + } + + /** + * Creates a tracked client on a keyset. `secured` means the secured keyset of the running step's role. `userId` gets the run + * suffix unless it already carries it. + */ + client(keyset: 'open' | 'secured' | Role, userId: string, extra: Partial = {}): PubNub { + const role: Role = keyset === 'secured' ? this.securedRole : keyset; + const keys = role === 'open' ? this.env.open : this.env[role]; + if (!keys) throw new Error(`${role} keyset is not configured`); + const config: PubNub.PubNubConfiguration = { + publishKey: keys.publishKey, + subscribeKey: keys.subscribeKey, + userId: userId.includes(`-r${this.runId}`) ? userId : this.n(userId), + logLevel: LOG_LEVELS[this.env.logLevel], + ...extra, + }; + return this.track(new PubNub(config)); + } + + /** A new open-keyset client with a unique user id, for steps that must not disturb shared clients. */ + fresh(label = 'fresh', extra: Partial = {}): PubNub { + this.counter += 1; + return this.client('open', `${label}-${this.counter}`, extra); + } + + /** + * Short heartbeat settings so presence waits stay small. The default presence timeout is 300 s, so a client that + * leaves without unsubscribing would linger in hereNow for five minutes. 20 s with an 8 s heartbeat keeps the waits in + * wait.ts (PRESENCE_TIMEOUT) realistic. + */ + get presenceOptions(): Partial { + return { presenceTimeout: 20, heartbeatInterval: 8 }; + } + + // Memoizes one client per role. Roles are shared across steps on purpose: tokens, listeners and subscriptions carry over, + // which mirrors an app that keeps one client. Steps that must not disturb that use `fresh()`. + // Secured roles are cached per keyset role, so `alice` of a pam step and `alice` of a datasync step are different clients. + private cast(role: ClientRole, build: () => PubNub): PubNub { + const key = role === 'server' || role === 'alice' || role === 'bob' ? `${this.securedRole}:${role}` : role; + let client = this.cache.get(key); + if (!client) { + client = build(); + this.cache.set(key, client); + } + return client; + } + + /** Open keyset, plain user. Most snippets run as this client. */ + get shopper(): PubNub { + return this.cast('shopper', () => this.client('open', 'shopper')); + } + get friend(): PubNub { + return this.cast('friend', () => this.client('open', 'friend')); + } + /** Never runs docs code. Subscribes and reads to verify effects. */ + get observer(): PubNub { + return this.cast('observer', () => this.client('open', 'observer')); + } + /** Secured keyset, secret key. Grants, DataSync writes, revokes. */ + get server(): PubNub { + return this.cast('server', () => { + const secured = this.securedKeys(); + // heartbeatInterval 0: a server that only signs grants has no use for presence heartbeats. + return registerGrantedTokens(this.client('secured', 'staff-server', { secretKey: secured.secretKey, heartbeatInterval: 0 })); + }); + } + /** Secured keyset, token holder. Call `setToken('alice', token)` after the grant step. */ + get alice(): PubNub { + return this.cast('alice', () => this.client('secured', 'user-alice')); + } + get bob(): PubNub { + return this.cast('bob', () => this.client('secured', 'user-bob')); + } + + setToken(role: 'alice' | 'bob', token: string): void { + (role === 'alice' ? this.alice : this.bob).setToken(token); + } + + // ---- helpers ------------------------------------------------------------------------------------------------ + + /** + * Wraps `obj[method]`, records calls and results, and calls through. Restored by `dispose()`. + * Used where the docs only show that something was called (a status listener, a transport) and the effect is internal. + */ + spy(obj: T, method: K): SpyHandle { + const original = (obj as Record)[method] as (...args: unknown[]) => unknown; + const handle: SpyHandle = { calls: [], results: [], restore: () => undefined }; + (obj as Record)[method] = function (this: unknown, ...args: unknown[]) { + const result = original.apply(obj, args); + handle.calls.push(args); + handle.results.push(result); + return result; + }; + handle.restore = () => { + (obj as Record)[method] = original; + }; + this.restorers.push(handle.restore); + return handle; + } + + /** + * Captures `console.log` (or another method) lines. The logging pages print through console, so asserting on the lines is + * the only way to prove the sample works. Call `restore()` in a `finally`. */ + captureConsole(method: 'log' | 'info' | 'debug' | 'warn' | 'error' = 'log'): ConsoleCapture { + const original = console[method]; + const lines: string[] = []; + console[method] = (...args: unknown[]) => { + lines.push(args.map((a) => (typeof a === 'string' ? a : safeJson(a))).join(' ')); + }; + const restore = (): void => { + console[method] = original; + }; + this.restorers.push(restore); + return { lines, restore }; + } + + /** Runs `fn` with `console.log` captured and restores it afterwards, also when `fn` throws. */ + async withConsole(fn: (logs: ConsoleCapture) => Promise | T): Promise { + const logs = this.captureConsole('log'); + try { + return await fn(logs); + } finally { + logs.restore(); + } + } + + /** + * Reusable shape checks for in-snippet or post-snippet assertions. A timetoken is checked the same way everywhere: a string + * of 17 digits (10 ns ticks since 1970), as the SDK types declare it. + */ + readonly check = { + timetoken(value: unknown): void { + assert.equal(typeof value, 'string', `expected a timetoken string, got ${typeof value} ${String(value)}`); + assert.match(String(value), /^\d{17}$/, `expected a 17 digit timetoken, got ${String(value)}`); + }, + /** + * The timetoken of `time()`. SDK finding: the types declare `timetoken: string` (src/core/endpoints/time.ts), but the + * SDK returns the parsed JSON number, which is above Number.MAX_SAFE_INTEGER and can lose its last digit. This check + * pins that behavior: when the SDK returns a string, it fails and the special case can go. + */ + serverTime(value: unknown): void { + assert.equal(typeof value, 'number', `time() timetoken: the SDK used to return a number although the types say string, got ${typeof value}`); + assert.match(String(value), /^\d{17}$/, `expected a 17 digit timetoken, got ${String(value)}`); + }, + }; + + /** Destroys every tracked client and restores spies and console. */ + async dispose(): Promise { + // Order: restore patched globals first so cleanup logging is not captured, then kill child processes (they hold + // subscriptions), then destroy clients, then stop the proxy their sockets might still use. + for (const restore of this.restorers.splice(0)) restore(); + for (const child of this.children) { + if (child.exitCode === null && !child.killed) child.kill('SIGKILL'); + } + this.children.clear(); + for (const client of this.tracked) { + try { + // Drop listeners first so the final unsubscribe status does not fire into a step that already finished. + client.removeAllListeners(); + client.destroy(); + } catch { + // A client that failed to build or was destroyed by its own step is fine. + } + } + this.tracked.clear(); + this.cache.clear(); + await this.proxy.stop(); + } +} + +/** + * Every token the server client grants becomes a known secret for the redaction (redact.cjs), so its exact value is masked + * in any output, on top of the token shape pattern. Samples print tokens with console.log, and a failing step shows its + * console output in the CI log and in report/run.json. + */ +function registerGrantedTokens(client: PubNub): PubNub { + const grant = client.grantToken.bind(client) as (...args: unknown[]) => unknown; + (client as unknown as { grantToken: (...args: unknown[]) => unknown }).grantToken = (...args: unknown[]) => { + const result = grant(...args); + if (result instanceof Promise) { + return result.then((token: unknown) => { + if (typeof token === 'string') addSecrets([token]); + return token; + }); + } + return result; + }; + return client; +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/docs-snippets/migration-guides/v11.ts b/docs-snippets/migration-guides/v11.ts new file mode 100644 index 000000000..c21daffde --- /dev/null +++ b/docs-snippets/migration-guides/v11.ts @@ -0,0 +1,107 @@ +import assert from '../harness/assert.js'; +import PubNub from 'pubnub'; +import { page, step, check } from '../harness/step.js'; +import { connected, eventually } from '../harness/wait.js'; + +/** + * docs/sdks/javascript/migration-guides/javascript-v11-migration-guide.md + * + * P0. The status listener sample of this guide is `status-events.ts` (`statusEventsMigrationBefore` and + * `statusEventsMigrationAfter`). The two configuration samples are hardcoded on the page, so the ids + * `migrationV11LegacySubscriptionManager` and `migrationV11RestoreFalse` are proposed ids, the docs page does not embed them yet. + * Browser-only behavior of `restore` and `listenToBrowserNetworkEvents` is not tested here. + * + * Phase P0, open keyset. In the Sneaker Drop story it is the upgrade note for a shop that upgrades the SDK and has to decide + * which old defaults to keep. Proves the two config switches are accepted and the legacy manager behaves as the guide says + * (`PNConnectedCategory` again on every channel change), the widened retry bounds, and `maintainPresenceState`. Does not prove + * `restore` or `listenToBrowserNetworkEvents` (they need a browser that loses its network). + */ +export default page('migration-guides/v11', 'P0', (world) => { + // The snippet pins the legacy subscription manager with `enableEventEngine: false`. The assertions around it prove the legacy + // signature: a second subscribe with a new channel fires PNConnectedCategory again and never PNSubscriptionChangedCategory. + step('migrationV11LegacySubscriptionManager', { proves: 'The legacy manager fires PNConnectedCategory again on a second subscribe and never PNSubscriptionChangedCategory.' }, async () => { + // snippet.migrationV11LegacySubscriptionManager + const pubnub = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + userId: 'myUserId', + enableEventEngine: false, // set explicitly to keep legacy behavior + // snippet.hide + ...world.live.open, + // snippet.show + }); + // snippet.end + world.track(pubnub); + // Legacy behavior: PNConnectedCategory fires again when the channel mix changes, and there is no PNSubscriptionChangedCategory. + const categories: string[] = []; + pubnub.addListener({ status: (s) => categories.push(String(s.category)) }); + const first = connected(pubnub, { channels: [world.n('ch1')] }); + pubnub.subscribe({ channels: [world.n('ch1')] }); + await first; + pubnub.subscribe({ channels: [world.n('ch2')] }); + await eventually(() => assert.equal(categories.filter((c) => c === 'PNConnectedCategory').length, 2), { label: 'second PNConnectedCategory' }); + assert.ok(!categories.includes('PNSubscriptionChangedCategory')); + pubnub.unsubscribeAll(); + }); + + // Smoke test only: a client built with `restore: false` is valid and works. + step('migrationV11RestoreFalse', { proves: 'A client built with restore false is valid and works.' }, async () => { + // snippet.migrationV11RestoreFalse + const pubnub = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + userId: 'myUserId', + restore: false, // set explicitly to keep legacy behavior + // snippet.hide + ...world.live.open, + // snippet.show + }); + // snippet.end + world.track(pubnub); + // The option is accepted and the client works. The network-loss behavior itself needs a browser. + world.check.serverTime((await pubnub.time()).timetoken); + }); + + // Offline: only the constructor is exercised. Version 11 lifted the upper bounds, so values the old SDK rejected must be accepted. + check('migrationV11RetryPolicyBounds', { proves: 'Retry policy values the old SDK rejected are accepted since version 11.' }, () => { + const open = world.live.open; + const config = (retryConfiguration: PubNub.RequestRetryPolicy): PubNub.PubNubConfiguration => ({ + subscribeKey: open.subscribeKey!, + userId: world.n('v11-bounds'), + retryConfiguration, + }); + // "Version 11.0.0 accepts" maximumRetry above 10 (linear), above 6 and maximumDelay above 150 (exponential). + world.track(new PubNub(config(PubNub.LinearRetryPolicy({ delay: 2, maximumRetry: 50 })))); + world.track(new PubNub(config(PubNub.ExponentialRetryPolicy({ minimumDelay: 2, maximumDelay: 600, maximumRetry: 20 })))); + // The minimum delay of the exponential policy is still checked. + assert.throws(() => new PubNub(config(PubNub.ExponentialRetryPolicy({ minimumDelay: 1, maximumDelay: 10, maximumRetry: 2 }))), /minimum|delay/i); + }); + + // Needs preflightPresence: the state is read back through the service (`getState`), which a keyset without Presence answers with + // an opaque 403. Two clients run side by side so the setting is the only difference. The waits are eventual consistency waits, + // presence state and leave events reach `getState` with a delay. + // "maintainPresenceState takes effect, so the client re-sends the presence state that you set on every subscribe call." + // disconnect() leaves the channel, which clears the state on the service. reconnect() joins again and sends the state again + // when the setting is on. + check('migrationV11MaintainPresenceState', { needs: ['preflightPresence'], timeout: 90_000, proves: 'With maintainPresenceState true the client re-sends its state after leaving and rejoining, and with false the state is gone.' }, async () => { + const channel = world.n('ch1'); + const kept = world.fresh('v11-state-kept', { ...world.presenceOptions }); + const dropped = world.fresh('v11-state-dropped', { ...world.presenceOptions, maintainPresenceState: false }); + const stateOn = async (client: PubNub): Promise => (await world.observer.getState({ uuid: client.getUserId(), channels: [channel] })).channels[channel]; + for (const client of [kept, dropped]) { + const first = connected(client, { channels: [channel] }); + client.subscribe({ channels: [channel], withPresence: true }); + await first; + await client.setState({ state: { mood: 'happy' }, channels: [channel] }); + await eventually(async () => assert.deepEqual(await stateOn(client), { mood: 'happy' }), { timeout: 30_000, label: 'state set' }); + client.disconnect(); + await eventually(async () => assert.deepEqual(await stateOn(client), {}), { timeout: 30_000, label: 'state cleared by the leave' }); + client.reconnect(); + } + await eventually(async () => assert.deepEqual(await stateOn(kept), { mood: 'happy' }), { timeout: 30_000, label: 'state re-sent when the client joins again' }); + // The other client joined at the same time, so its state would be back by now if it were sent. + assert.notDeepEqual(await stateOn(dropped), { mood: 'happy' }, 'with maintainPresenceState: false the state is gone after leaving'); + kept.unsubscribeAll(); + dropped.unsubscribeAll(); + }); +}); diff --git a/docs-snippets/migration-guides/v12.ts b/docs-snippets/migration-guides/v12.ts new file mode 100644 index 000000000..ee4373702 --- /dev/null +++ b/docs-snippets/migration-guides/v12.ts @@ -0,0 +1,72 @@ +import assert from '../harness/assert.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import PubNub from 'pubnub'; +import { page, check } from '../harness/step.js'; +import { LocalProxy } from '../harness/proxy.js'; + +/** + * docs/sdks/javascript/migration-guides/javascript-v12-migration-guide.md + * + * P7. The proxy sample of this guide is `setProxyBasicUsage` (api-reference/misc.ts, the docs embed that id). These checks + * prove the claims around it: environment proxy variables are no longer read, `setProxy()` accepts an HTTP proxy object + * or URI string, and it throws with `keepAlive`. The Node.js minimum is asserted in `quickstartNodeVersion`. + * + * Phase P7, open keyset. In the Sneaker Drop story it is + * the upgrade note for a shop behind a corporate proxy. Proves the proxy claims, the optional `react-native-url-polyfill` + * peer dependency and the Node.js minimum of the SDK manifest. Does not prove a proxy that needs authentication or HTTPS + * proxies, the local proxy is plain HTTP. + */ +const SDK_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('pubnub'))), '..', '..'); + +export default page('migration-guides/v12', 'P7', (world) => { + // Sets every proxy environment variable to the local proxy and counts CONNECT requests: zero means the SDK ignored them. + // The variables are restored in `finally` because they are process-wide and would leak into later steps. + check('migrationV12ProxyEnvironmentVariablesIgnored', { timeout: 45_000, proves: 'The SDK ignores proxy environment variables, shown by zero tunnel requests at the local proxy.' }, async () => { + // A proxy of its own: world.proxy is shared, and clients of earlier proxy steps may still tunnel through it, which would + // move the counter while the environment variables are set. + const proxy = new LocalProxy(); + await proxy.start(); + const names = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy']; + const saved = Object.fromEntries(names.map((n) => [n, process.env[n]])); + const uri = `http://${proxy.host}:${proxy.port}`; + for (const n of names) process.env[n] = uri; + try { + const pubnub = world.fresh('v12-env'); + const before = proxy.connects; + world.check.serverTime((await pubnub.time()).timetoken); + assert.equal(proxy.connects, before, 'proxy environment variables are not read'); + + // "read the variable in your own code and pass the proxy address to setProxy()": a URI string works. + pubnub.setProxy(process.env.HTTPS_PROXY!); + world.check.serverTime((await pubnub.time()).timetoken); + assert.ok(proxy.connects > before, 'setProxy(uri) routes requests through the proxy'); + pubnub.setProxy(); + } finally { + await proxy.stop(); + for (const n of names) { + if (saved[n] === undefined) delete process.env[n]; + else process.env[n] = saved[n]; + } + } + }); + + // Offline: the throw happens inside `setProxy()`, no request is sent. + check('migrationV12SetProxyKeepAlive', { proves: 'setProxy throws when the client was built with keepAlive true, before any request.' }, () => { + // "setProxy() ... throws if keepAlive is true." + const pubnub = world.fresh('v12-keepalive', { keepAlive: true }); + assert.throws(() => pubnub.setProxy({ hostname: '127.0.0.1', port: 8080, protocol: 'http' }), /keepAlive/); + }); + + // Reads the manifest of the linked SDK checkout, so it tracks whatever version the suite is wired to. `void PubNub` keeps the import used. + check('migrationV12ReactNativePolyfill', { proves: 'The SDK manifest lists react-native-url-polyfill as an optional peer dependency and requires Node 22 or later.' }, () => { + // "Version 12.0.0 lists react-native-url-polyfill as an optional peer dependency, so installing pubnub no longer installs it." + const manifest = JSON.parse(fs.readFileSync(path.join(SDK_ROOT, 'package.json'), 'utf8')); + assert.ok(manifest.peerDependencies?.['react-native-url-polyfill']); + assert.equal(manifest.peerDependenciesMeta?.['react-native-url-polyfill']?.optional, true); + assert.ok(!manifest.dependencies?.['react-native-url-polyfill']); + assert.equal(manifest.engines.node, '>=22.0.0'); + void PubNub; + }); +}); diff --git a/docs-snippets/migration-guides/v8.ts b/docs-snippets/migration-guides/v8.ts new file mode 100644 index 000000000..033b93fe1 --- /dev/null +++ b/docs-snippets/migration-guides/v8.ts @@ -0,0 +1,55 @@ +// snippet.migrationV8AfterImport +import PubNub, { AppContext } from 'pubnub' + +// snippet.end +import assert from '../harness/assert.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { page, step, check } from '../harness/step.js'; +import { tscProject } from '../harness/tsc-project.js'; + +/** + * docs/sdks/javascript/migration-guides/javascript-v8-migration-guide.md + * + * Type-only guide. The "after" sample is compiled as part of this app (`tsc --noEmit`), the "before" sample and the list + * of API group namespaces are compiled with a separate `tsc` run. The page hardcodes both samples as fragments, so the + * ids `migrationV8AfterImport` and `migrationV8AfterParams` are proposed ids, the docs page does not embed them yet. + * + * Phase P0, no keyset (nothing here touches the network). In the Sneaker Drop story it is the upgrade note for an old shop + * codebase moving from 7.x.x. Proves that the 8.x.x import form compiles, that the 7.x.x form really stopped compiling and + * that every namespace the page lists is exported. Does not prove any runtime behavior, the guide has none. + */ +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +export default page('migration-guides/v8', 'P0', () => { + // The import-only snippet `migrationV8AfterImport` sits at the top of this file and is checked by `tsc --noEmit` of the whole app. + // `void PubNub` only marks the default import as used. The typed constant is the proof: it fails to compile if + // `AppContext.GetMembershipsParameters` is renamed or moved. + step('migrationV8AfterParams', { proves: 'The typed parameters constant of the v8 guide compiles against the SDK types.' }, () => { + void PubNub; + // snippet.migrationV8AfterParams + // other code + + const params: AppContext.GetMembershipsParameters = {}; + // snippet.end + assert.deepEqual(params, {}); + }); + + // Runs a separate `tsc` over scratch files (harness/tsc-project.ts) because the "before" sample must FAIL to compile, which + // cannot live in a file that the suite's own typecheck has to pass. The long timeout is two cold `tsc` processes. + check('migrationV8TypeImports', { timeout: 90_000, proves: 'The before sample for type imports fails to compile and the after sample compiles.' }, () => { + // The 7.x.x import no longer compiles: the type moved into the AppContext namespace. + const before = tscProject(HERE, { + 'before.ts': `import PubNub, { GetMembershipsParameters } from 'pubnub'\n\nconst params: GetMembershipsParameters = {};\n`, + }); + assert.equal(before.ok, false, 'importing GetMembershipsParameters directly fails in 8.x.x'); + assert.match(before.output, /GetMembershipsParameters/); + + // The 8.x.x import compiles, and so does every API group namespace that the page lists. + const namespaces = ['Subscription', 'AppContext', 'ChannelGroups', 'Publish', 'Signal', 'Presence', 'History', 'MessageAction', 'FileSharing', 'PAM', 'Time', 'Push']; + const after = tscProject(HERE, { + 'after.ts': `import PubNub, { ${namespaces.join(', ')} } from 'pubnub'\n\nconst params: AppContext.GetMembershipsParameters = {};\nvoid PubNub; void params;\n`, + }); + assert.ok(after.ok, `the 8.x.x import and every namespace compile.\n${after.output}`); + }); +}); diff --git a/docs-snippets/package.json b/docs-snippets/package.json new file mode 100644 index 000000000..094aa8942 --- /dev/null +++ b/docs-snippets/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "private": true +} diff --git a/docs-snippets/provision/datasync-classes.json b/docs-snippets/provision/datasync-classes.json new file mode 100644 index 000000000..9cd93dc1f --- /dev/null +++ b/docs-snippets/provision/datasync-classes.json @@ -0,0 +1,63 @@ +{ + "$comment": "Desired DataSync classes on the secured docs keyset. Applied by provision-datasync.ts (dry run by default). Only SubKey-level classes are managed here. The Global User, Channel and Membership classes are seeded by PubNub and cannot be changed. Event publishing per class version has no Admin API endpoint and is switched on in the Admin Portal (DataSync tab, Select entities to sync): User v1, Channel v1, Membership v1, product v1, ProductOwner v1.", + "entityClasses": [ + { + "name": "product", + "version": 1, + "description": "Sneaker product used by the JavaScript SDK docs snippet tests. The public projection exposes name and price, admin exposes everything.", + "config": { "ttlSec": 2678400 }, + "properties": [ + { + "name": "name", + "path": "/payload/name", + "valueKind": "string", + "filtering": "full", + "isNullable": true, + "projections": [{ "name": "__default__" }, { "name": "public" }, { "name": "admin" }] + }, + { + "name": "price", + "path": "/payload/price", + "valueKind": "number", + "filtering": "simple", + "isNullable": false, + "projections": [{ "name": "__default__" }, { "name": "public" }, { "name": "admin" }] + }, + { + "name": "stock", + "path": "/payload/stock", + "valueKind": "number", + "filtering": "simple", + "isNullable": true, + "projections": [{ "name": "__default__" }, { "name": "admin" }] + } + ] + } + ], + "relationshipClasses": [ + { + "name": "ProductOwner", + "version": 1, + "description": "Links a seller (entity A, any class) to a product (entity B) in the JavaScript SDK docs snippet tests.", + "cardinality": "many-to-many", + "entityBClass": "product", + "properties": [ + { + "name": "tier", + "path": "/payload/tier", + "valueKind": "string", + "filtering": "full", + "isNullable": true, + "projections": [{ "name": "__default__" }, { "name": "public" }, { "name": "admin" }] + } + ] + } + ], + "expectedBuiltIns": { + "entityClasses": [ + { "name": "User", "version": 1, "level": "Global" }, + { "name": "Channel", "version": 1, "level": "Global" } + ], + "relationshipClasses": [{ "name": "Membership", "version": 1 }] + } +} diff --git a/docs-snippets/provision/provision-datasync.ts b/docs-snippets/provision/provision-datasync.ts new file mode 100644 index 000000000..6e603eaf5 --- /dev/null +++ b/docs-snippets/provision/provision-datasync.ts @@ -0,0 +1,363 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeRoles, loadEnv, type Env } from '../harness/env.js'; +import { installRedaction, redact } from '../harness/redact.cjs'; + +/** + * Sets up the DataSync classes of the datasync keyset from `datasync-classes.json`. + * + * npm run docs-snippets:provision creates missing classes, updates classes whose mutable fields drifted + * npm run docs-snippets:provision -- --check no-write diff, exit code 1 when anything differs + * + * The live suite calls `provisionDataSync` first in every run that includes the datasync role, before preflight and before any + * step (harness/cli.ts). + * + * Rules: + * - Never deletes a class. A class whose immutable fields differ (relationship cardinality or entity sides) fails with the + * diff, because only a person may recreate it. + * - Idempotent. A second run reports every class as unchanged and writes nothing. + * - Touches only the classes in datasync-classes.json. Classes other tests use on a shared keyset (for example the `JS*` + * classes of test/integration/endpoints/data_sync/helpers.ts) are never read for changes and never written. + * - Without PUBNUB_ADMIN_API_KEY it still checks, through the SDK with the datasync keys, and a missing class fails with its + * name. The DataSync steps are never skipped silently. + * - The per class event switch ("Select entities to sync") has no Admin API endpoint. It stays a feature probe + * (`needs-feature:datasync-events`, step datasyncEvents). + * - Output never contains a key or part of one. + * + * Admin API auth is the raw key in the `Authorization` header (no `Bearer`) plus `PubNub-Version`. + */ + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const BASE = 'https://admin-api.pubnub.com/v2/datasync/subkeys'; +const API_VERSION = '2026-09-17'; +const ENTITY_TYPE = 'application/vnd.pubnub.objects.entity-class+json;version=1'; +const RELATIONSHIP_TYPE = 'application/vnd.pubnub.objects.relationship-class+json;version=1'; + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +type Obj = { [key: string]: Json }; + +interface Desired { + entityClasses: Obj[]; + relationshipClasses: Obj[]; + expectedBuiltIns: { entityClasses: Obj[]; relationshipClasses: Obj[] }; +} + +interface Action { + kind: 'entity' | 'relationship'; + op: 'create' | 'replace' | 'unchanged' | 'blocked'; + name: string; + version: number; + diff: string[]; + body?: Obj; +} + +export interface ProvisionResult { + ok: boolean; + /** Lines for the console. No key material. */ + log: string[]; + /** What failed the setup, one line each. */ + problems: string[]; + /** Number of POST and PUT calls made. 0 on a second run. */ + writes: number; +} + +async function request( + method: string, + url: string, + apiKey: string, + contentType?: string, + body?: Obj, +): Promise<{ status: number; json: Obj }> { + const headers: Record = { Authorization: apiKey, 'PubNub-Version': API_VERSION }; + if (contentType) headers['Content-Type'] = contentType; + const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined }); + const text = await response.text(); + let json: Obj = {}; + try { + json = text ? (JSON.parse(text) as Obj) : {}; + } catch { + // Redact first, then cut: a key cut in half would match neither its literal value nor its shape. + json = { raw: redact(text).slice(0, 200) }; + } + return { status: response.status, json }; +} + +/** Subset comparison: every key in `desired` must match in `actual`. Arrays compare element by element. */ +function differences(actual: Json | undefined, desired: Json, where: string): string[] { + if (Array.isArray(desired)) { + if (!Array.isArray(actual)) return [`${where}: expected an array, found ${JSON.stringify(actual)}`]; + const out: string[] = []; + if (actual.length !== desired.length) + out.push(`${where}: expected ${desired.length} item(s), found ${actual.length}`); + desired.forEach((item, i) => { + const named = typeof item === 'object' && item !== null && 'name' in item; + const label = named ? `${where}[${(item as Obj).name}]` : `${where}[${i}]`; + const match = named ? actual.find((a) => (a as Obj)?.name === (item as Obj).name) : actual[i]; + out.push(...differences(match, item, label)); + }); + return out; + } + if (desired !== null && typeof desired === 'object') { + if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) + return [`${where}: expected an object, found ${JSON.stringify(actual)}`]; + return Object.entries(desired).flatMap(([key, value]) => + differences((actual as Obj)[key], value, where ? `${where}.${key}` : key), + ); + } + return actual === desired ? [] : [`${where}: ${JSON.stringify(actual)} -> ${JSON.stringify(desired)}`]; +} + +function listOf(json: Obj): Obj[] { + return Array.isArray(json.data) ? (json.data as Obj[]) : []; +} + +function entityBody(d: Obj): Obj { + const data: Obj = { config: d.config as Json, properties: d.properties as Json }; + if (d.description) data.description = d.description; + return { data }; +} + +function relationshipCreateBody(d: Obj): Obj { + const data: Obj = { cardinality: d.cardinality as Json }; + for (const key of [ + 'description', + 'entityAClass', + 'entityAClassLevel', + 'entityBClass', + 'entityBClassLevel', + 'properties', + ]) { + if (d[key] !== undefined) data[key] = d[key] as Json; + } + return { data }; +} + +function relationshipReplaceBody(d: Obj): Obj { + const data: Obj = {}; + if (d.description) data.description = d.description; + if (d.properties) data.properties = d.properties; + return { data }; +} + +function desiredClasses(): Desired { + return JSON.parse(fs.readFileSync(path.join(HERE, 'datasync-classes.json'), 'utf8')) as Desired; +} + +/** Without an admin key: check through the SDK that each class exists. Never writes. */ +async function checkWithSdk(env: Env, desired: Desired): Promise { + const log: string[] = [ + 'PUBNUB_ADMIN_API_KEY is not set: checking the classes through the SDK with the datasync keys (no writes)', + ]; + const problems: string[] = []; + const keys = env.datasync; + if (!keys) + return { + ok: false, + log, + problems: ['the datasync keyset is not configured (DS_PUBLISH_KEY, DS_SUBSCRIBE_KEY, DS_SECRET_KEY)'], + writes: 0, + }; + const { default: PubNub } = await import('pubnub'); + const client = new PubNub({ + ...keys, + userId: 'docs-snippets-provision-check', + logLevel: PubNub.LogLevel.None, + heartbeatInterval: 0, + }); + try { + for (const d of desired.entityClasses) { + try { + await client.dataSync.getEntities({ class: String(d.name), classVersion: Number(d.version), limit: 1 }); + log.push(` = entity class ${d.name} v${d.version} exists`); + } catch (error) { + problems.push( + `DataSync entity class ${d.name} v${d.version} is missing on the datasync keyset (${(error as { status?: { statusCode?: number } }).status?.statusCode ?? (error as Error).message}). Set PUBNUB_ADMIN_API_KEY and run npm run docs-snippets:provision.`, + ); + } + } + for (const d of desired.relationshipClasses) { + try { + await client.dataSync.getRelationships({ class: String(d.name), classVersion: Number(d.version), limit: 1 }); + log.push(` = relationship class ${d.name} v${d.version} exists`); + } catch (error) { + problems.push( + `DataSync relationship class ${d.name} v${d.version} is missing on the datasync keyset (${(error as { status?: { statusCode?: number } }).status?.statusCode ?? (error as Error).message}). Set PUBNUB_ADMIN_API_KEY and run npm run docs-snippets:provision.`, + ); + } + } + } finally { + client.destroy(); + } + return { ok: problems.length === 0, log, problems, writes: 0 }; +} + +/** + * Reads the live classes, compares them with datasync-classes.json and, unless `check`, creates missing classes and updates + * drifted mutable fields. See the header for the rules. + */ +export async function provisionDataSync(env: Env, options: { check: boolean }): Promise { + const desired = desiredClasses(); + if (!env.adminKey) return checkWithSdk(env, desired); + const log: string[] = []; + const problems: string[] = []; + const subKey = env.datasync?.subscribeKey; + if (!subKey) + return { ok: false, log, problems: ['the datasync keyset is not configured (DS_SUBSCRIBE_KEY)'], writes: 0 }; + const apiKey = env.adminKey; + const entities = await request('GET', `${BASE}/${subKey}/entity-classes`, apiKey); + const relationships = await request('GET', `${BASE}/${subKey}/relationship-classes`, apiKey); + if (entities.status !== 200 || relationships.status !== 200) { + const hint = + entities.status === 403 || relationships.status === 403 + ? ' The Service Integration needs a DataSync permission row (Keyset level, Read & write) for the datasync keyset.' + : ''; + return { + ok: false, + log, + problems: [ + `could not read the live classes: entity-classes HTTP ${entities.status}, relationship-classes HTTP ${relationships.status}.${hint}`, + ], + writes: 0, + }; + } + const liveEntities = listOf(entities.json); + const liveRelationships = listOf(relationships.json); + log.push( + `datasync keyset: ${liveEntities.length} entity class(es), ${liveRelationships.length} relationship class(es) live`, + ); + + for (const builtIn of desired.expectedBuiltIns.entityClasses) { + if (!liveEntities.some((c) => c.name === builtIn.name && c.version === builtIn.version)) + problems.push( + `built-in entity class ${builtIn.name} v${builtIn.version} is missing, so DataSync is off on the datasync keyset`, + ); + } + for (const builtIn of desired.expectedBuiltIns.relationshipClasses) { + if (!liveRelationships.some((c) => c.name === builtIn.name && c.version === builtIn.version)) + problems.push( + `built-in relationship class ${builtIn.name} v${builtIn.version} is missing, so DataSync is off on the datasync keyset`, + ); + } + + const actions: Action[] = []; + for (const d of desired.entityClasses) { + const live = liveEntities.find((c) => c.name === d.name && c.version === d.version && c.level === 'SubKey'); + const { name, version, ...comparable } = d; + void name; + void version; + if (!live) + actions.push({ + kind: 'entity', + op: 'create', + name: String(d.name), + version: Number(d.version), + diff: ['(new class)'], + body: entityBody(d), + }); + else { + const diff = differences(live as Json, comparable as Json, ''); + actions.push({ + kind: 'entity', + op: diff.length ? 'replace' : 'unchanged', + name: String(d.name), + version: Number(d.version), + diff, + body: entityBody(d), + }); + } + } + for (const d of desired.relationshipClasses) { + const live = liveRelationships.find((c) => c.name === d.name && c.version === d.version); + const { name, version, ...comparable } = d; + void name; + void version; + if (!live) + actions.push({ + kind: 'relationship', + op: 'create', + name: String(d.name), + version: Number(d.version), + diff: ['(new class)'], + body: relationshipCreateBody(d), + }); + else { + const diff = differences(live as Json, comparable as Json, ''); + const immutable = diff.filter((line) => /^(cardinality|entityA|entityB)/.test(line)); + if (immutable.length) + actions.push({ + kind: 'relationship', + op: 'blocked', + name: String(d.name), + version: Number(d.version), + diff: immutable, + }); + else + actions.push({ + kind: 'relationship', + op: diff.length ? 'replace' : 'unchanged', + name: String(d.name), + version: Number(d.version), + diff, + body: relationshipReplaceBody(d), + }); + } + } + + for (const a of actions) { + const symbol = { create: '+', replace: '~', unchanged: '=', blocked: '!' }[a.op]; + log.push(` ${symbol} ${a.kind} class ${a.name} v${a.version} [${a.op}]`); + if (a.op !== 'unchanged') for (const line of a.diff) log.push(` ${line}`); + } + for (const a of actions.filter((x) => x.op === 'blocked')) { + problems.push( + `${a.kind} class ${a.name} v${a.version} differs in an immutable field (${a.diff.join('; ')}). Only a person may delete and recreate it.`, + ); + } + + const pending = actions.filter((a) => a.op === 'create' || a.op === 'replace'); + let writes = 0; + if (options.check) { + log.push( + pending.length + ? `check: ${pending.length} change(s) would be written, nothing was written` + : 'check: every class is unchanged', + ); + for (const a of pending) + problems.push(`${a.kind} class ${a.name} v${a.version} would be ${a.op === 'create' ? 'created' : 'updated'}`); + } else { + for (const a of pending) { + const dir = a.kind === 'entity' ? 'entity-classes' : 'relationship-classes'; + const url = `${BASE}/${subKey}/${dir}/${encodeURIComponent(a.name)}/versions/${a.version}`; + const type = a.kind === 'entity' ? ENTITY_TYPE : RELATIONSHIP_TYPE; + const result = await request(a.op === 'create' ? 'POST' : 'PUT', url, apiKey, type, a.body); + writes += 1; + const ok = result.status === 200 || result.status === 201; + log.push(` ${a.op === 'create' ? 'POST' : 'PUT'} ${a.kind} ${a.name} v${a.version}: HTTP ${result.status}`); + if (!ok) + problems.push( + `${a.op} of ${a.kind} class ${a.name} v${a.version} failed with HTTP ${result.status}: ${redact(JSON.stringify(result.json)).slice(0, 300)}`, + ); + } + log.push(`${writes} write(s), ${actions.filter((a) => a.op === 'unchanged').length} class(es) unchanged`); + } + return { ok: problems.length === 0, log, problems, writes }; +} + +const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (invokedDirectly) { + installRedaction(); + const env = loadEnv(); + console.log(describeRoles(env)); + provisionDataSync(env, { check: process.argv.includes('--check') }).then( + (result) => { + result.log.forEach((line) => console.log(line)); + result.problems.forEach((p) => console.error(`problem: ${p}`)); + process.exitCode = result.ok ? 0 : 1; + }, + (error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }, + ); +} diff --git a/docs-snippets/quickstarts/browser-download-file.ts b/docs-snippets/quickstarts/browser-download-file.ts new file mode 100644 index 000000000..81db503be --- /dev/null +++ b/docs-snippets/quickstarts/browser-download-file.ts @@ -0,0 +1,34 @@ +import type PubNub from 'pubnub'; + +/** + * docs/sdks/javascript/api-reference/file-sharing.md, "Download file", the browser sample. + * + * Type checked here by tsconfig.browser.json. It cannot be run in this repository, which has no browser. The page assumes a + * client named `pubnub`, declared below. + * The hidden `@ts-expect-error` records an SDK typing bug: the shipped types declare `toFile(): Promise`. + */ +declare const pubnub: PubNub; + +// snippet.downloadFileWebBasicUsage +// In browser +// download the intended file +const downloadFile = async () => { + const file = await pubnub.downloadFile({ + channel: 'my_channel', + id: '...', + name: 'cat_picture.jpg', + }); + + // have proper html element to display the file + const myImageTag = document.createElement('img'); + // snippet.hide + // @ts-expect-error the shipped types declare toFile(): Promise, the browser returns a File (SDK bug, STATUS.md) + // snippet.show + myImageTag.src = URL.createObjectURL(await file!.toFile()); + + // attach the file content to the html element + document.body.appendChild(myImageTag); +}; +// snippet.end + +export { downloadFile }; diff --git a/docs-snippets/quickstarts/browser-shared-worker.ts b/docs-snippets/quickstarts/browser-shared-worker.ts new file mode 100644 index 000000000..53d9cb463 --- /dev/null +++ b/docs-snippets/quickstarts/browser-shared-worker.ts @@ -0,0 +1,23 @@ +import PubNub from 'pubnub'; + +/** + * docs/sdks/javascript/set-up-your-environment.md, the SharedWorker sample. + * + * Type checked here by tsconfig.browser.json. It cannot be run in this repository, which has no browser. The hidden + * `@ts-expect-error` records an SDK typing bug: the shipped types lack `subscriptionWorkerUrl`. + */ +// snippet.configurationBasicUsageSubscriptionWorkerUrl +const pubnub = new PubNub({ + subscribeKey: 'demo', + publishKey: 'demo', + userId: 'unique-user-id', + // using PubNub JS SDK v9.6.0, make sure the versions match. + // NOTE: 'subscriptionWorkerUrl' is only needed if you need to use SharedWorker. + // snippet.hide + // @ts-expect-error the shipped types lack subscriptionWorkerUrl (SDK bug, STATUS.md) + // snippet.show + subscriptionWorkerUrl: 'https://www.my-domain.com/static/js/pubnub.worker.9.6.0.js', +}); +// snippet.end + +export { pubnub }; diff --git a/docs-snippets/quickstarts/quickstart-react-app.tsx b/docs-snippets/quickstarts/quickstart-react-app.tsx new file mode 100644 index 000000000..487576fba --- /dev/null +++ b/docs-snippets/quickstarts/quickstart-react-app.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import PubNub from 'pubnub'; + +// Create the client once, outside the component, so re-renders reuse it +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +export default function App() { + const [messages, setMessages] = useState([]); + + useEffect(() => { + const subscription = pubnub.channel('hello_world').subscription(); + + subscription.onMessage = (event) => { + setMessages((previous) => [...previous, `${event.publisher}: ${event.message}`]); + }; + subscription.subscribe(); + + // Runs on unmount, and before React re-runs the effect in development (StrictMode) + return () => { + subscription.unsubscribe(); + }; + }, []); + + const [text, setText] = useState(''); + + const send = async () => { + if (!text.trim()) return; + + try { + await pubnub.publish({ channel: 'hello_world', message: text }); + setText(''); + } catch (error) { + console.error(`Publish failed: ${error}`); + } + }; + + return ( +
+
    + {messages.map((message, index) => ( +
  • {message}
  • + ))} +
+ setText(event.target.value)} placeholder="Type a message" /> + +
+ ); +} diff --git a/docs-snippets/quickstarts/quickstart-react-native-app.tsx b/docs-snippets/quickstarts/quickstart-react-native-app.tsx new file mode 100644 index 000000000..729ca6c9f --- /dev/null +++ b/docs-snippets/quickstarts/quickstart-react-native-app.tsx @@ -0,0 +1,370 @@ +// App.tsx + +// Imports +import React, { useState, useEffect, useRef } from 'react'; +import { + StyleSheet, + View, + Text, + TextInput, + TouchableOpacity, + FlatList, + KeyboardAvoidingView, + Platform, + StatusBar, + ActivityIndicator, + Alert +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import PubNub from 'pubnub'; + +// Types for the messages +type ChatMessage = { + text: string; + sender?: string; + time?: string; +}; + +type MessageItem = { + id: string; + message: ChatMessage; +}; + +// Main App Component +export default function App() { + // State variables + const [pubnub, setPubnub] = useState(null); + const [messages, setMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const [isConnected, setIsConnected] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // Reference to the FlatList for auto-scrolling + const flatListRef = useRef(null); + + // Initialize PubNub on component mount + useEffect(() => { + // Create a new PubNub instance + const pubnubInstance = new PubNub({ + publishKey: "demo", + subscribeKey: "demo", + userId: "rn-user-" + Math.floor(Math.random() * 1000) + }); + + // Add listener to handle events + pubnubInstance.addListener({ + // Message handler - called when a message is received + message: function(event) { + setMessages(messages => [ + ...messages, + { + id: event.timetoken || Date.now().toString(), + message: event.message as ChatMessage + } + ]); + + // Auto-scroll to bottom when new messages arrive + setTimeout(() => { + if (flatListRef.current) { + flatListRef.current.scrollToEnd({ animated: true }); + } + }, 100); + }, + + // Presence handler - called for join/leave events + presence: function(event) { + console.log("Presence event:", event); + // Skip interval events and your own events + if (event.action === 'interval' || event.uuid === pubnubInstance.getUserId()) return; + if (event.action === 'join') { + // Someone joined the channel + Alert.alert("User Joined", `${event.uuid} joined the chat`); + } else if (event.action === 'leave' || event.action === 'timeout') { + // Someone left the channel + Alert.alert("User Left", `${event.uuid} left the chat`); + } + }, + + // Status handler - called for connection status changes + status: function(event) { + console.log("Status event:", event); + if (event.category === "PNConnectedCategory") { + setIsConnected(true); + setIsLoading(false); + console.log("Connected to PubNub!"); + } else if (event.category === "PNNetworkIssuesCategory") { + setIsConnected(false); + console.log("Connection lost. Attempting to reconnect..."); + } else if (event.category === "PNReconnectedCategory") { + setIsConnected(true); + console.log("Reconnected to PubNub!"); + } + } + }); + + // Create a channel entity and subscription + const channel = pubnubInstance.channel('hello_world'); + const subscription = channel.subscription({ + receivePresenceEvents: true + }); + + // Subscribe to the channel + subscription.subscribe(); + + // Update state with PubNub instance + setPubnub(pubnubInstance); + + // Clean up on component unmount + return () => { + pubnubInstance.removeAllListeners(); + pubnubInstance.destroy(); + console.log("Cleaned up PubNub connection"); + }; + }, []); // Empty dependency array ensures this runs only once on mount + + // Function to publish a message + const publishMessage = async () => { + // Don't send empty messages or if not connected + if (!inputText.trim() || !pubnub || !isConnected) return; + + try { + // Create message object + const messageObject: ChatMessage = { + text: inputText, + sender: pubnub.getUserId(), + time: new Date().toISOString() + }; + + // Publish to PubNub + const result = await pubnub.publish({ + message: messageObject, + channel: 'hello_world' + }); + + console.log("Message published with timetoken:", result.timetoken); + + // Clear input after successful send + setInputText(''); + } catch (error) { + console.error("Publish failed:", error); + Alert.alert( + "Message Failed", + "Could not send your message. Please try again." + ); + } + }; + + // Render a message item for the FlatList + const renderMessageItem = ({ item }: { item: MessageItem }) => { + const isCurrentUser = item.message.sender === pubnub?.getUserId(); + const messageTime = item.message.time + ? new Date(item.message.time).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }) + : ''; + + return ( + + + {isCurrentUser ? 'You' : (item.message.sender || 'User')} + + {item.message.text} + {messageTime} + + ); + }; + + // Main component render + return ( + + + + {isLoading ? ( + // Loading screen + + + Connecting to PubNub... + + ) : ( + // Chat UI + + {/* Header */} + + PubNub Chat + + + + {/* Messages List */} + item.id} + style={styles.messagesList} + contentContainerStyle={styles.messagesContent} + onContentSizeChange={() => + flatListRef.current?.scrollToEnd({ animated: false }) + } + /> + + {/* Input Area */} + + + + Send + + + + )} + + ); +} + +// Component styles +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 10, + fontSize: 16, + color: '#555', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + backgroundColor: '#fff', + }, + headerText: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + }, + connectionIndicator: { + width: 12, + height: 12, + borderRadius: 6, + }, + connected: { + backgroundColor: '#4CAF50', + }, + disconnected: { + backgroundColor: '#F44336', + }, + messagesList: { + flex: 1, + padding: 8, + }, + messagesContent: { + paddingBottom: 8, + }, + messageContainer: { + padding: 12, + borderRadius: 16, + marginVertical: 4, + maxWidth: '80%', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 1, + elevation: 1, + }, + sentMessage: { + alignSelf: 'flex-end', + backgroundColor: '#DCF8C6', + borderBottomRightRadius: 4, + }, + receivedMessage: { + alignSelf: 'flex-start', + backgroundColor: '#fff', + borderBottomLeftRadius: 4, + }, + senderText: { + fontWeight: 'bold', + fontSize: 13, + marginBottom: 4, + color: '#333', + }, + messageText: { + fontSize: 15, + color: '#333', + }, + timeText: { + fontSize: 11, + color: '#999', + alignSelf: 'flex-end', + marginTop: 4, + }, + inputContainer: { + flexDirection: 'row', + padding: 12, + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + backgroundColor: '#fff', + }, + input: { + flex: 1, + height: 40, + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 20, + paddingHorizontal: 16, + marginRight: 8, + backgroundColor: '#fff', + color: '#333', + }, + sendButton: { + height: 40, + paddingHorizontal: 16, + borderRadius: 20, + backgroundColor: '#4b0082', + justifyContent: 'center', + alignItems: 'center', + }, + sendButtonDisabled: { + backgroundColor: '#B39DDB', + }, + sendButtonText: { + color: '#fff', + fontWeight: 'bold', + }, +}); diff --git a/docs-snippets/quickstarts/quickstart-react-native.tsx b/docs-snippets/quickstarts/quickstart-react-native.tsx new file mode 100644 index 000000000..14c1adbe5 --- /dev/null +++ b/docs-snippets/quickstarts/quickstart-react-native.tsx @@ -0,0 +1,128 @@ +/** + * Snippet source of the React Native quickstart (docs/sdks/javascript/quickstart-react-native.md), the step-by-step + * blocks. The page shows them as loose fragments, so each one sits inside a small host component here, with the names + * the fragment expects (`pubnub`, `styles`, the React Native components) in scope. The snippet bodies are the exact text + * of the page. + * + * `quickstart-react-native-app.tsx` is the complete `App.tsx` ("Set up event listeners" and "Complete example", the same + * text twice on the page). This repository keeps the files for the docs URLs. It cannot run or compile them, because the + * SDK has no React Native dependencies. + * + * Not run by Mocha (no `page()` module, no phase). + * `quickstart-react-native-app.tsx` has no comments of its own, its text is the docs text. The odd indentation of the + * snippet bodies below is the docs text too. + */ +import React, { useEffect, useState } from 'react'; +import { Alert, Button, StyleSheet, TextInput, View } from 'react-native'; + +// snippet.quickstartReactNativeImport +import PubNub from 'pubnub'; +// snippet.end + +const styles = StyleSheet.create({ container: { flex: 1 }, input: { height: 40 } }); + +/** Hosts the "Initialize" fragment. The caller gets the client the fragment creates. */ +export function createClient(): PubNub { + // snippet.quickstartReactNativeInit + const pubnub = new PubNub({ + publishKey: "demo", + subscribeKey: "demo", + userId: "rn-user-" + Math.floor(Math.random() * 1000) + }); + // snippet.end + return pubnub; +} + +/** Hosts the "optional" cleanup fragment of the "Initialize" section. */ +export function CleanupHost({ pubnub }: { pubnub: PubNub | null }) { + // snippet.quickstartReactNativeCleanup + // Example of initialization in a React component (optional) + useEffect(() => { + // Clean up on component unmount + return () => { + if (pubnub) { + pubnub.removeAllListeners(); + pubnub.destroy(); + } + }; + }, [pubnub]); + // snippet.end + return null; +} + +/** Hosts the "Create a subscription" fragment. */ +export function SubscribeHost({ pubnub }: { pubnub: PubNub | null }) { + // snippet.quickstartReactNativeSubscription + useEffect(() => { + if (!pubnub) return; + + // Create a channel entity + const channel = pubnub.channel('hello_world'); + + // Create a subscription + const subscription = channel.subscription({ + receivePresenceEvents: true + }); + + // Subscribe + subscription.subscribe(); + + // Clean up on unmount + return () => { + // First remove listeners to prevent callbacks during cleanup + pubnub.removeAllListeners(); + + // Then unsubscribe if needed + if (subscription) { + subscription.unsubscribe(); + } + + // Note: Only call destroy() if this component owns the pubnub instance + // If the pubnub instance is shared, consider only removing listeners + }; + }, [pubnub]); + // snippet.end + return null; +} + +/** Hosts the "Publish messages" fragment. It renders the input and the button the fragment returns. */ +export function PublishHost({ pubnub }: { pubnub: PubNub | null }) { + // snippet.quickstartReactNativePublish + // State for the input text + const [inputText, setInputText] = useState(''); + + // Function to publish a message + const publishMessage = async () => { + if (!inputText.trim() || !pubnub) return; + + try { + const result = await pubnub.publish({ + message: { + text: inputText, + sender: pubnub.getUserId(), + time: new Date().toISOString() + }, + channel: 'hello_world' + }); + console.log("Message published with timetoken:", result.timetoken); + setInputText(''); // Clear input after sending + } catch (error) { + console.error("Publish failed:", error); + Alert.alert("Error", "Failed to send message"); + } + }; + + // In your render function + return ( + + + + + ); +} diff --git a/docs-snippets/quickstarts/quickstart-web.html b/docs-snippets/quickstarts/quickstart-web.html new file mode 100644 index 000000000..b2085ce07 --- /dev/null +++ b/docs-snippets/quickstarts/quickstart-web.html @@ -0,0 +1,56 @@ + + + + + PubNub quickstart + + + +

PubNub quickstart

+
    + + + + + + diff --git a/docs-snippets/quickstarts/quickstart-web.ts b/docs-snippets/quickstarts/quickstart-web.ts new file mode 100644 index 000000000..f62e0578c --- /dev/null +++ b/docs-snippets/quickstarts/quickstart-web.ts @@ -0,0 +1,64 @@ +import PubNub from 'pubnub'; + +/** + * Snippet source of the Web quickstart (docs/sdks/javascript/quickstart-web.md), the four step-by-step blocks. + * + * The page loads the SDK from the CDN, which exposes the `PubNub` global. The import above only lets the compiler check + * the snippets (`npm run test:docs-snippets:types`, tsconfig.browser.json, with the DOM library). The snippet bodies are the + * exact text the page shows. The page that combines them is `quickstart-web.html`. This repository cannot run either file, + * because it has no browser. + * + * The publish snippet keeps its try and catch because the page teaches that error handling. Standalone quickstart files + * are exempt from the "snippets let errors throw" rule of the Node snippets (see lint-markers.ts, STANDALONE_DIR). + * + * Not run by Mocha: there is no `page()` module and no phase. Companion files with no comments of their own, because their text is the docs text: + * `quickstart-web.html`. + */ + +// snippet.quickstartWebInit +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); +// snippet.end + +// snippet.quickstartWebListeners +pubnub.addListener({ + message: (event) => { + // Add every incoming message to the list on the page + const item = document.createElement('li'); + item.textContent = `${event.publisher}: ${event.message}`; + document.querySelector('ul')?.append(item); + }, + status: (event) => { + if (event.category === 'PNConnectedCategory') { + console.log('Connected to PubNub'); + } + }, +}); +// snippet.end + +// snippet.quickstartWebSubscribe +const channel = pubnub.channel('hello_world'); +const subscription = channel.subscription(); +subscription.subscribe(); +// snippet.end + +// snippet.quickstartWebPublish +document.querySelector('button')?.addEventListener('click', async () => { + const input = document.querySelector('input'); + if (!input || !input.value.trim()) return; + + try { + const result = await pubnub.publish({ + channel: 'hello_world', + message: input.value, + }); + console.log(`Published, timetoken: ${result.timetoken}`); + input.value = ''; + } catch (error) { + console.error(`Publish failed: ${error}`); + } +}); +// snippet.end diff --git a/docs-snippets/quickstarts/react-configuration.jsx b/docs-snippets/quickstarts/react-configuration.jsx new file mode 100644 index 000000000..f7dfc6c32 --- /dev/null +++ b/docs-snippets/quickstarts/react-configuration.jsx @@ -0,0 +1,29 @@ +// docs/sdks/javascript/api-reference/configuration.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactConfigurationLifecycle +import React, { useRef, useEffect } from 'react'; +import PubNub from 'pubnub'; + +function App() { + const pubnubRef = useRef(null); + + useEffect(() => { + pubnubRef.current = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', + }); + + return () => { + if (pubnubRef.current) { + pubnubRef.current.removeAllListeners(); + pubnubRef.current.destroy(); + } + }; + }, []); + + return
    Your app content
    ; +} +// snippet.end + +export default App; diff --git a/docs-snippets/quickstarts/react-fetch-messages.jsx b/docs-snippets/quickstarts/react-fetch-messages.jsx new file mode 100644 index 000000000..bccf02ca0 --- /dev/null +++ b/docs-snippets/quickstarts/react-fetch-messages.jsx @@ -0,0 +1,47 @@ +// docs/sdks/javascript/api-reference/message-persistence.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactFetchMessages +import React, { useState, useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function MessageHistory() { + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function loadHistory() { + try { + const result = await pubnub.fetchMessages({ + channels: ['my_channel'], + count: 25, + }); + const channelMessages = result.channels['my_channel'] ?? []; + setMessages(channelMessages.map((m) => m.message)); + } catch (error) { + console.error('fetchMessages failed:', error); + } finally { + setIsLoading(false); + } + } + loadHistory(); + }, []); + + if (isLoading) return

    Loading history...

    ; + + return ( +
      + {messages.map((msg, i) => ( +
    • {typeof msg === 'object' ? JSON.stringify(msg) : msg}
    • + ))} +
    + ); +} +// snippet.end + +export default MessageHistory; diff --git a/docs-snippets/quickstarts/react-get-state.jsx b/docs-snippets/quickstarts/react-get-state.jsx new file mode 100644 index 000000000..123743048 --- /dev/null +++ b/docs-snippets/quickstarts/react-get-state.jsx @@ -0,0 +1,35 @@ +// docs/sdks/javascript/api-reference/presence.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactGetState +import React, { useState, useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function UserState() { + const [presenceState, setPresenceState] = useState(null); + + useEffect(() => { + async function fetchState() { + try { + const result = await pubnub.getState({ + uuid: pubnub.userId, + channels: ['my_channel'], + }); + setPresenceState(result.channels); + } catch (error) { + console.error('getState failed:', error); + } + } + fetchState(); + }, []); + + return
    {JSON.stringify(presenceState, null, 2)}
    ; +} +// snippet.end + +export default UserState; diff --git a/docs-snippets/quickstarts/react-here-now.jsx b/docs-snippets/quickstarts/react-here-now.jsx new file mode 100644 index 000000000..eb95762f5 --- /dev/null +++ b/docs-snippets/quickstarts/react-here-now.jsx @@ -0,0 +1,48 @@ +// docs/sdks/javascript/api-reference/presence.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactHereNow +import React, { useState, useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function ChannelOccupancy() { + const [occupancy, setOccupancy] = useState(null); + const [onlineUsers, setOnlineUsers] = useState([]); + + useEffect(() => { + async function fetchPresence() { + try { + const result = await pubnub.hereNow({ + channels: ['my_channel'], + includeUUIDs: true, + includeState: true, + }); + const channel = result.channels['my_channel']; + setOccupancy(channel.occupancy); + setOnlineUsers(channel.occupants.map((o) => o.uuid)); + } catch (error) { + console.error('hereNow failed:', error); + } + } + fetchPresence(); + }, []); + + return ( +
    +

    Users online: {occupancy ?? '...'}

    +
      + {onlineUsers.map((uuid) => ( +
    • {uuid}
    • + ))} +
    +
    + ); +} +// snippet.end + +export default ChannelOccupancy; diff --git a/docs-snippets/quickstarts/react-listeners.jsx b/docs-snippets/quickstarts/react-listeners.jsx new file mode 100644 index 000000000..410313bf1 --- /dev/null +++ b/docs-snippets/quickstarts/react-listeners.jsx @@ -0,0 +1,56 @@ +// docs/sdks/javascript/api-reference/subscribe.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactListeners +import React, { useState, useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function Chat() { + const [messages, setMessages] = useState([]); + const [onlineCount, setOnlineCount] = useState(0); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + pubnub.addListener({ + message: (event) => { + setMessages((prev) => [...prev, event.message]); + }, + presence: (event) => { + // event.action: 'join' | 'leave' | 'timeout' | 'interval' | 'state-change' + // occupancy is present on join/leave/timeout/interval but not on state-change + if (event.action !== 'state-change') setOnlineCount(event.occupancy); + }, + status: (event) => { + setIsConnected(event.category === 'PNConnectedCategory'); + }, + }); + + const channel = pubnub.channel('my_channel'); + const subscription = channel.subscription({ receivePresenceEvents: true }); + subscription.subscribe(); + + return () => { + subscription.unsubscribe(); + pubnub.removeAllListeners(); + }; + }, []); + + return ( +
    +

    {isConnected ? `Connected — ${onlineCount} online` : 'Connecting...'}

    +
      + {messages.map((msg, i) => ( +
    • {typeof msg === 'object' ? JSON.stringify(msg) : msg}
    • + ))} +
    +
    + ); +} +// snippet.end + +export default Chat; diff --git a/docs-snippets/quickstarts/react-native-download-file.ts b/docs-snippets/quickstarts/react-native-download-file.ts new file mode 100644 index 000000000..8e52160aa --- /dev/null +++ b/docs-snippets/quickstarts/react-native-download-file.ts @@ -0,0 +1,23 @@ +import type PubNub from 'pubnub'; + +/** + * docs/sdks/javascript/api-reference/file-sharing.md, "Download file", the React and React Native sample. + * + * Not compiled and not run here, because the SDK has no React Native dependencies and this repository has no browser. The + * page shows loose statements that use a client named `pubnub`, so they sit in a function that receives it. Only the lines + * between the markers are on the page. + */ +export async function downloadFileSample(pubnub: PubNub): Promise { + // snippet.downloadFileReactNativeBasicUsage + // in React and React Native + const file = await pubnub.downloadFile({ + channel: 'awesomeChannel', + id: 'imageId', + name: 'cat_picture.jpg', + }); + + // toArrayBuffer() works in React and React Native (toBlob() is not supported in React Native) + const fileContent = await file.toArrayBuffer(); + // snippet.end + return fileContent; +} diff --git a/docs-snippets/quickstarts/react-presence-events.jsx b/docs-snippets/quickstarts/react-presence-events.jsx new file mode 100644 index 000000000..9c6b33966 --- /dev/null +++ b/docs-snippets/quickstarts/react-presence-events.jsx @@ -0,0 +1,54 @@ +// docs/sdks/javascript/api-reference/presence.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactPresenceEvents +import React, { useState, useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function PresenceTracker() { + const [onlineUsers, setOnlineUsers] = useState([]); + const [occupancy, setOccupancy] = useState(0); + + useEffect(() => { + pubnub.addListener({ + presence: (event) => { + // event.action: 'join' | 'leave' | 'timeout' | 'interval' | 'state-change' + if (event.action === 'join') { + setOnlineUsers((prev) => [...prev.filter((id) => id !== event.uuid), event.uuid]); + } else if (event.action === 'leave' || event.action === 'timeout') { + setOnlineUsers((prev) => prev.filter((id) => id !== event.uuid)); + } + // occupancy is present on join/leave/timeout/interval but not on state-change + if (event.action !== 'state-change') setOccupancy(event.occupancy); + }, + }); + + const channel = pubnub.channel('my_channel'); + const subscription = channel.subscription({ receivePresenceEvents: true }); + subscription.subscribe(); + + return () => { + subscription.unsubscribe(); + pubnub.removeAllListeners(); + }; + }, []); + + return ( +
    +

    Online users: {occupancy}

    +
      + {onlineUsers.map((userId) => ( +
    • {userId}
    • + ))} +
    +
    + ); +} +// snippet.end + +export default PresenceTracker; diff --git a/docs-snippets/quickstarts/react-set-state.jsx b/docs-snippets/quickstarts/react-set-state.jsx new file mode 100644 index 000000000..9591f8068 --- /dev/null +++ b/docs-snippets/quickstarts/react-set-state.jsx @@ -0,0 +1,33 @@ +// docs/sdks/javascript/api-reference/presence.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactSetState +import React, { useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function UserWithPresenceState() { + useEffect(() => { + async function setPresenceState() { + try { + const response = await pubnub.setState({ + state: { status: 'online' }, + channels: ['my_channel'], + }); + console.log('State set successfully:', response); + } catch (error) { + console.error('setState failed:', error); + } + } + setPresenceState(); + }, []); + + return
    User with presence state
    ; +} +// snippet.end + +export default UserWithPresenceState; diff --git a/docs-snippets/quickstarts/react-subscribe.jsx b/docs-snippets/quickstarts/react-subscribe.jsx new file mode 100644 index 000000000..79ea840b4 --- /dev/null +++ b/docs-snippets/quickstarts/react-subscribe.jsx @@ -0,0 +1,30 @@ +// docs/sdks/javascript/api-reference/subscribe.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactSubscribe +import React, { useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function SubscribedComponent() { + useEffect(() => { + const channel = pubnub.channel('my_channel'); + const subscription = channel.subscription({ + receivePresenceEvents: true, + }); + subscription.subscribe(); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + return
    Subscribed to my_channel
    ; +} +// snippet.end + +export default SubscribedComponent; diff --git a/docs-snippets/quickstarts/react-where-now.jsx b/docs-snippets/quickstarts/react-where-now.jsx new file mode 100644 index 000000000..75e129bb6 --- /dev/null +++ b/docs-snippets/quickstarts/react-where-now.jsx @@ -0,0 +1,41 @@ +// docs/sdks/javascript/api-reference/presence.md, React tab. Between the markers: the exact text of the page. The export at the end of +// the file is not on the page (the page does not export the component), a separate React test project imports it. +// snippet.reactWhereNow +import React, { useState, useEffect } from 'react'; +import PubNub from 'pubnub'; + +const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', +}); + +function UserChannels() { + const [channels, setChannels] = useState([]); + + useEffect(() => { + async function fetchChannels() { + try { + const result = await pubnub.whereNow({ uuid: pubnub.userId }); + setChannels(result.channels); + } catch (error) { + console.error('whereNow failed:', error); + } + } + fetchChannels(); + }, []); + + return ( +
    +

    Subscribed channels:

    +
      + {channels.map((ch) => ( +
    • {ch}
    • + ))} +
    +
    + ); +} +// snippet.end + +export default UserChannels; diff --git a/docs-snippets/run.ts b/docs-snippets/run.ts new file mode 100644 index 000000000..8efed9332 --- /dev/null +++ b/docs-snippets/run.ts @@ -0,0 +1,107 @@ +/** + * The plan: the one place that lists every page module of the suite. + * + * Role. `harness/mocha-entry.ts` imports this file from the staged copy (`docs-snippets/.run//run.ts`), calls + * `registerAll(world)` and turns each registered step into one Mocha test. A page module is a file that default + * exports `page(path, phase, setup)` (see `harness/step.ts`). It only registers steps when `setup(world)` runs, so + * importing the modules here has no side effects. + * + * Order. Steps sort by phase first (P0 to P9), then by the position of their page module in `plan` below, then by + * registration order inside the page. So this list decides the order inside one phase only. The phases are: P0 boot, P1 secure (Access Manager), P2 catalog (DataSync), P3 rooms (App Context, channel + * groups), P4 live (subscribe, publish, presence, files), P5 history, P6 push, P7 network (proxy, outage), P8 legacy, + * P9 teardown. Each page module sets its own default phase and a step may override it. + * + * Adding a page. Create `api-reference/.ts` (same base name as the docs page), import it here and add it to + * `plan`. `npm run test:docs-snippets:dry` then checks that every snippet the lint finds is claimed by exactly one step. + */ +import { registry } from './harness/registry.js'; +import type { PageModule } from './harness/step.js'; +import type { World } from './harness/world.js'; +import preflight from './harness/preflight.js'; +import preflightSummary from './harness/preflight-summary.js'; +import shared from './shared/import-pubnub.js'; +import configuration from './api-reference/configuration.js'; +import channelGroups from './api-reference/channel-groups.js'; +import publish from './api-reference/publish.js'; +import subscribe from './api-reference/subscribe.js'; +import presence from './api-reference/presence.js'; +import messageActions from './api-reference/message-actions.js'; +import messagePersistence from './api-reference/message-persistence.js'; +import misc from './api-reference/misc.js'; +import accessManager from './api-reference/access-manager.js'; +import accessManagerV2 from './api-reference/access-manager-v2.js'; +import accessManagerLegacy from './api-reference/access-manager-legacy.js'; +import encryption from './api-reference/encryption.js'; +import encryptionLegacy from './api-reference/encryption-legacy.js'; +import objects from './api-reference/objects.js'; +import datasync from './api-reference/datasync.js'; +import fileSharing from './api-reference/file-sharing.js'; +import fileSharingLegacy from './api-reference/file-sharing-legacy.js'; +import mobilePush from './api-reference/mobile-push.js'; +import subscribeLegacy from './api-reference/subscribe-legacy.js'; +import publishLegacy from './api-reference/publish-legacy.js'; +import messagePersistenceLegacy from './api-reference/message-persistence-legacy.js'; +import configurationLegacy from './api-reference/configuration-legacy.js'; +import quickstartNode from './getting-started/quickstart-node.js'; +import setUpYourEnvironment from './getting-started/set-up-your-environment.js'; +import logging from './getting-started/logging.js'; +import loggingFileLogger from './getting-started/logging-file-logger.js'; +import statusEvents from './getting-started/status-events.js'; +import troubleshooting from './getting-started/troubleshooting.js'; +import migrationV8 from './migration-guides/v8.js'; +import migrationV11 from './migration-guides/v11.js'; +import migrationV12 from './migration-guides/v12.js'; + +/** + * Page modules. Steps carry their own phase, so the order here only decides registration order inside a + * phase. The runner sorts by phase (P0 to P9), then by this order. + * + * Dependencies that must hold are written as `needs` on the step, which the registry validates (a need that runs + * later is a wiring error), so the order here is only a tie-breaker inside a phase. + */ +export const plan: PageModule[] = [ + preflight, + shared, + configuration, + channelGroups, + publish, + subscribe, + presence, + messageActions, + messagePersistence, + misc, + mobilePush, + fileSharingLegacy, + fileSharing, + objects, + datasync, + accessManager, + accessManagerV2, + accessManagerLegacy, + encryption, + encryptionLegacy, + // Legacy pages (P8). + subscribeLegacy, + publishLegacy, + messagePersistenceLegacy, + configurationLegacy, + // Getting started, logging, status events, troubleshooting, migration guides. + quickstartNode, + setUpYourEnvironment, + logging, + loggingFileLogger, + statusEvents, + troubleshooting, + migrationV8, + migrationV11, + migrationV12, + // Last, so its P0 checks run after every other P0 preflight and can name every missing feature at once. + preflightSummary, +]; + +/** Runs every page module's `setup`, which registers its steps in the global registry. Call `registry.reset()` first. */ +export function registerAll(world: World): void { + for (const module of plan) module.setup(world); +} + +export { registry }; diff --git a/docs-snippets/shared/import-pubnub.ts b/docs-snippets/shared/import-pubnub.ts new file mode 100644 index 000000000..2a11aa806 --- /dev/null +++ b/docs-snippets/shared/import-pubnub.ts @@ -0,0 +1,47 @@ +// snippet.importPubNub +import PubNub from 'pubnub'; +// snippet.end +// snippet.importFS +import fs from 'fs'; +// snippet.end +import assert from '../harness/assert.js'; +import { page, step } from '../harness/step.js'; + +/** + * Shared snippet ids that many docs pages embed next to their own snippet: `importPubNub` (the one line import) and + * `PubNubinitBasicUsage` (the generic client constructor). They belong to no single page, hence the `shared/` folder. + * + * Keyset: open. Phase: P0 (boot). Story: this is the first thing Sneaker Drop code does, build a client from the keys. + * + * Proves: the constructor snippet runs with real keys (hidden `...world.live.open` replaces the placeholders), the user + * id gets the run suffix from the stage rewrite (`YOUR_USER_ID` is registered), and a request that needs the subscribe key + * succeeds. Does not prove: any feature beyond reaching the service. Configuration options live in configuration.ts. + * + * `importPubNub` and `importFS` are import-only and sit at module level, which the marker lint allows for imports. They are + * compiled by `tsc` (a rename of the default export breaks `npm run test:docs-snippets:types`) but no step runs them. `importFS` lives here + * because the file sharing page shows it between `importPubNub` and `PubNubinitBasicUsage` in one block. The file sharing + * steps use their own `fs` import (api-reference/file-sharing.ts). + */ +export default page('shared/import-pubnub', 'P0', (world) => { + // Constructs the docs client with live keys, then proves it works with a cheap keyed call (`time`). + step('PubNubinitBasicUsage', { proves: 'The generic init sample builds a working client and time() succeeds.' }, async () => { + // snippet.PubNubinitBasicUsage + // Initialize PubNub with your keys + const pubnub = new PubNub({ + publishKey: 'YOUR_PUBLISH_KEY', + subscribeKey: 'YOUR_SUBSCRIBE_KEY', + userId: 'YOUR_USER_ID', + // snippet.hide + ...world.live.open, + // snippet.show + }); + // snippet.end + + // The snippet builds its own client, so register it for destroy() at the end of the run. + world.track(pubnub); + assert.equal(pubnub.getUserId(), world.n('YOUR_USER_ID')); + // The user id came through the staged rewrite, so it carries the run suffix. + // The keys are real: a request that needs the subscribe key succeeds. + world.check.serverTime((await pubnub.time()).timetoken); + }); +}); diff --git a/docs-snippets/tsconfig.browser.json b/docs-snippets/tsconfig.browser.json new file mode 100644 index 000000000..07b8b70b0 --- /dev/null +++ b/docs-snippets/tsconfig.browser.json @@ -0,0 +1,19 @@ +{ + "//": "Type check only. Browser samples of the docs that need a DOM: the web quickstart, the browser file download and the shared worker configuration. React and React Native sources are not checked here (the SDK has no react, react-native or expo dependencies).", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": [], + "baseUrl": ".", + "paths": { + "pubnub": ["../lib/types/index.d.ts"] + } + }, + "include": ["quickstarts/quickstart-web.ts", "quickstarts/browser-download-file.ts", "quickstarts/browser-shared-worker.ts"] +} diff --git a/docs-snippets/tsconfig.json b/docs-snippets/tsconfig.json index 4dee2f901..0395b690b 100644 --- a/docs-snippets/tsconfig.json +++ b/docs-snippets/tsconfig.json @@ -20,6 +20,24 @@ "**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + ".run", + "report", + "api-reference", + "getting-started", + "migration-guides", + "shared", + "quickstarts", + "harness", + "fixtures", + "provision", + "run.ts", + ".mocharc.cjs", + "coverage.json", + "package.json", + "tsconfig.live.json", + "tsconfig.run.json", + "tsconfig.browser.json", + "README.md" ] } \ No newline at end of file diff --git a/docs-snippets/tsconfig.live.json b/docs-snippets/tsconfig.live.json new file mode 100644 index 000000000..6f982f27a --- /dev/null +++ b/docs-snippets/tsconfig.live.json @@ -0,0 +1,33 @@ +{ + "//": "Live docs snippet suite. Used by tsx at run time (TSX_TSCONFIG_PATH) and by tsc (npm run test:docs-snippets:types). The first pubnub candidate is the built SDK that tsx loads, tsc skips it (no allowJs) and takes the shipped types.", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node", "mocha"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "allowJs": false, + "baseUrl": ".", + "paths": { + "pubnub": ["../lib/types/index.d.ts", "../lib/node/index.js"] + } + }, + "include": [ + "run.ts", + "harness/**/*.ts", + "fixtures/**/*.ts", + "provision/**/*.ts", + "shared/**/*.ts", + "api-reference/**/*.ts", + "getting-started/**/*.ts", + "migration-guides/**/*.ts" + ], + "exclude": ["node_modules", ".run", "report"] +} diff --git a/docs-snippets/tsconfig.run.json b/docs-snippets/tsconfig.run.json new file mode 100644 index 000000000..85fe23354 --- /dev/null +++ b/docs-snippets/tsconfig.run.json @@ -0,0 +1,9 @@ +{ + "//": "Run time only (tsx reads it through TSX_TSCONFIG_PATH). Same options as tsconfig.live.json, but pubnub resolves to the built SDK of this repository, lib/node/index.js. lib/node has no .d.ts, so type checking uses tsconfig.live.json, which maps pubnub to the shipped types.", + "extends": "./tsconfig.live.json", + "compilerOptions": { + "paths": { + "pubnub": ["../lib/node/index.js"] + } + } +} diff --git a/package.json b/package.json index 40e5b5112..ac462d04d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,13 @@ "clean": "rimraf lib dist upload", "lint": "eslint \"src/**/*\" --config .eslintrc.cjs", "test:snippets": "tsc --project docs-snippets/tsconfig.json --noEmit", + "test:docs-snippets": "TSX_TSCONFIG_PATH=docs-snippets/tsconfig.run.json tsx docs-snippets/harness/cli.ts live", + "test:docs-snippets:dry": "TSX_TSCONFIG_PATH=docs-snippets/tsconfig.run.json tsx docs-snippets/harness/cli.ts dry", + "test:docs-snippets:janitor": "TSX_TSCONFIG_PATH=docs-snippets/tsconfig.run.json tsx docs-snippets/harness/cli.ts janitor", + "test:docs-snippets:types": "tsc -p docs-snippets/tsconfig.live.json && tsc -p docs-snippets/tsconfig.browser.json", + "docs-snippets:summary": "node docs-snippets/harness/snippet-summary.cjs", + "docs-snippets:manifest": "TSX_TSCONFIG_PATH=docs-snippets/tsconfig.run.json tsx docs-snippets/harness/cli.ts manifest", + "docs-snippets:provision": "TSX_TSCONFIG_PATH=docs-snippets/tsconfig.run.json tsx docs-snippets/provision/provision-datasync.ts", "ci": "npm run clean && npm run build && npm run lint && npm run test", "ci:web": "npm run clean && npm run build:web && npm run lint && npm run test:web && npm run test:web:shared-worker", "ci:node": "npm run clean && npm run build:node && npm run lint && npm run test:node",