Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
groups:
npm-minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ jobs:
with:
node-version: 22
cache: "pnpm"
- run: pnpm install
- run: pnpm install --frozen-lockfile
- run: pnpm schema:piefed:check
- run: pnpm format:check
- run: pnpm lint
- run: pnpm test:types
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/live-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,26 @@ on:
- cron: "0 12 * * 1" # Mondays 12:00 UTC
workflow_dispatch:

permissions:
contents: read

concurrency:
group: live-smoke
cancel-in-progress: false

jobs:
live-smoke:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- run: npm i -fg corepack && corepack enable
- uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"
- run: pnpm install
- run: pnpm vitest run test/live-smoke.test.ts test/live-fidelity.test.ts
- run: pnpm install --frozen-lockfile
- run: pnpm vitest run test/live-smoke.test.ts test/live-fidelity.test.ts test/live-authenticated.test.ts
env:
LIVE_PIEFED_ROUTES: "1"
LIVE_SMOKE: "1"
38 changes: 38 additions & 0 deletions .github/workflows/piefed-schema-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: piefed-schema-drift

on:
schedule:
- cron: "0 13 * * 1"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: piefed-schema-drift
cancel-in-progress: false

jobs:
drift:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- run: npm i -fg corepack && corepack enable
- uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Compare authoritative PieFed OpenAPI document
env:
PIEFED_SCHEMA_DRIFT_OUTPUT: ${{ runner.temp }}/piefed-openapi-current.json
run: pnpm schema:piefed:drift
- name: Upload changed upstream document
if: failure()
uses: actions/upload-artifact@v7
with:
name: piefed-openapi-drift
path: ${{ runner.temp }}/piefed-openapi-current.json
if-no-files-found: ignore
retention-days: 14
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ dist
coverage
node_modules

# local disposable-account credentials for opt-in live acceptance
/.test-creds.json

# generated docs
docs/api
docs/.vitepress/dist
docs/.vitepress/cache

.nuxt
.output
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
src/providers/piefed/schema.ts
src/providers/piefed/openapi.snapshot.json
pnpm-lock.yaml
docs/api
docs/.vitepress/dist
docs/.vitepress/cache
docs/.vitepress/cache
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { ThreadiverseClient } from "threadiverse";

const client = new ThreadiverseClient("https://lemmy.world");

const posts = await client.getPosts();
const posts = await client.getPosts({});
```

See the [documentation](https://aeharding.github.io/threadiverse/) for
Expand All @@ -50,6 +50,6 @@ authentication, software discovery, pagination, and error handling.
## Testing your app

`threadiverse/testing` provides fakes for consumer test suites — seed
content, inject errors, assert request payloads — with one API across
content, inject errors, assert request payloads — with a shared core API across
providers. See the
[testing guide](https://aeharding.github.io/threadiverse/guide/testing).
67 changes: 66 additions & 1 deletion docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,71 @@ getters [`client.mode`](/api/index/classes/ThreadiverseClient#mode) and
[`client.software`](/api/index/classes/ThreadiverseClient#software) work
too.

### Endpoint capabilities

Preflight optional features before showing their UI instead of calling an
endpoint and catching `UnsupportedError`:

```ts
if (await client.supports("getFederatedInstances")) {
const { federated_instances } = await client.getFederatedInstances();
// Show Voyager's instance browser
}
```

For an endpoint with provider-specific parameter support, include the relevant
parameter in the check. For example, PieFed supports its native upvoted feed but
does not expose a complete downvoted feed:

```ts
const canShowUpvoted = await client.supports("listPersonLiked", {
like_type: "liked_only",
});
const canShowDownvoted = await client.supports("listPersonLiked", {
like_type: "disliked_only",
});
```

The same payload-aware form covers other partial provider features, such as
PieFed's inability to remove all of a person's existing community content as
part of a ban:

```ts
const canBanAndRemoveContent = await client.supports("banFromCommunity", {
remove_or_restore_data: true,
});
```

Image deletion also varies by provider. Lemmy v0 requires the nonempty
pictrs token returned by its uploader; Lemmy v1 and PieFed verify ownership
from authentication and can delete a tokenless upload:

```ts
const image = { delete_token: upload.delete_token ?? "", url: upload.url };
if (await client.supports("deleteImage", image)) {
await client.deleteImage(image);
}
```

PieFed's activity-alert switches are another partial feature. It can represent
`all_posts` or `replies_and_mentions` for a community, and `all_comments` or
`replies_and_mentions` for a post. It cannot represent the richer `mute` mode
or community `all_posts_and_comments`, so preflight the exact mode before
offering it:

```ts
const canMuteCommunity = await client.supports("editCommunityNotifications", {
mode: "mute",
}); // false on PieFed
```

`supports()` connects implicitly. After `connect()`, complete endpoint support
is also available on both the returned `capabilities` map and the sync
`client.capabilities` getter. These endpoint-level flags are conservative: a
provider that supports only some valid parameters is `false`. Neither form
guarantees that the current account is authorized or that the instance's policy
permits the operation.

Discovery results are cached per hostname in a process-wide cache by
default. Pass your own `Map` as `discoveryCache` to scope it — useful
server-side or in tests:
Expand Down Expand Up @@ -94,7 +159,7 @@ await client.createComment({
content: "Nice post!",
});

await client.likePost({ post_id: post_view.post.id, score: 1 });
await client.likePost({ is_upvote: true, post_id: post_view.post.id });
```

Every method also accepts trailing
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { ThreadiverseClient } from "threadiverse";

const client = new ThreadiverseClient("https://lemmy.world");

const { data: posts } = await client.getPosts();
const { data: posts } = await client.getPosts({});
```

The first API call resolves the instance's software via
Expand Down
111 changes: 90 additions & 21 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { FakeLemmyV1Instance, FakePiefedInstance } from "threadiverse/testing";
```

[`FakeLemmyV1Instance`](/api/testing/classes/FakeLemmyV1Instance) and
[`FakePiefedInstance`](/api/testing/classes/FakePiefedInstance) expose an
identical API (both extend
[`FakeInstance`](/api/testing/classes/FakeInstance)). Wire knowledge lives
inside this package, type-checked against the same upstream types the
compat layers consume, and verified against real instances (see
[`FakePiefedInstance`](/api/testing/classes/FakePiefedInstance) share the same
core fake-instance API (both extend
[`FakeInstance`](/api/testing/classes/FakeInstance)); their supported
operations and wire builders remain provider-specific. Wire knowledge lives
inside this package, type-checked against the same upstream types the compat
layers consume. Selected behavior is also checked against real instances (see
[Fidelity verification](#fidelity-verification)).

## Quick start
Expand All @@ -24,7 +25,7 @@ import { FakeLemmyV1Instance } from "threadiverse/testing";

const fake = new FakeLemmyV1Instance();

// Content: seed it; every read endpoint derives from the store
// Content: supported seed-backed read endpoints derive from the store
const alex = fake.seed.person({ name: "alex" });
fake.seed.post({ name: "Hello **world**", creator: alex });
fake.seed.loggedInAs(alex);
Expand All @@ -50,9 +51,9 @@ route-level escape hatch, not the primary interface.
## Content: the seed store

`fake.seed` is a [`SeedStore`](/api/testing/classes/SeedStore) — a semantic
content store. Seed what exists, and **all read endpoints derive from it
consistently** — feeds, post detail, comments, site counts, profiles,
notifications:
content store. Seed what exists, and **supported seed-backed read endpoints
derive from it consistently** — feeds, post detail, comments, site counts,
profiles, notifications:

```ts
const alex = fake.seed.person({ displayName: "Alex", name: "alex" });
Expand Down Expand Up @@ -129,8 +130,8 @@ await client.getPosts({});
`once` calls queue, making fail-then-succeed flows explicit:

```ts
fake.once.followCommunity({ error: { code: "rate_limit_error", status: 429 } });
// first attempt fails, retry succeeds against the default route
fake.once.getPosts({ error: { code: "rate_limit_error", status: 429 } });
// first attempt fails; retry uses the seed-derived feed
```

### Custom wire responses
Expand Down Expand Up @@ -158,17 +159,22 @@ Assert on outgoing requests as **canonical payloads** — what your app
_meant_, decoded from the wire and round-trip tested per provider:

```ts
// Wait for the next request to an operation
// Works before or after the request: returns the latest matching payload,
// or waits when none has been recorded yet
const payload = await fake.waitForPayload("likePost");
// { post_id: 1, is_upvote: true }

// When ordering matters, ignore history and wait strictly for the next one
const nextPayload = await fake.waitForNextPayload("likePost");

// Or inspect everything an operation has received
const calls = fake.callsTo("likePost");
expect(calls).toHaveLength(1);
```

`waitForPayload` accepts an optional predicate to wait for a specific
matching request. Both are part of the per-operation
Both waiters accept an optional predicate for a specific matching request.
The same distinction exists at the wire level as `waitForCall` versus
`waitForNextCall`. All are part of the per-operation
[`OperationApi`](/api/testing/interfaces/OperationApi), alongside `on` and
`once`.

Expand Down Expand Up @@ -208,17 +214,80 @@ the UI; combine with `waitForPayload` to assert what the app sent.

Requests to the fake's host that no seed route or override handles return
`501` with a console warning (`[FakeInstance] unmocked request: …`), and
requests to foreign origins throw a `TypeError` — a spec drifting to the
network fails fast instead of hanging.
`fake.fetch` throws a `TypeError` for foreign-origin requests. The Playwright
`install(page)` adapter leaves foreign origins untouched.

## Fidelity verification

The fakes are verified against reality in two ways:

- Wire shapes are **type-checked against the same upstream API types** the
compat layers use (`lemmy-js-client`, PieFed's Swagger).
- A scheduled suite verifies the fakes' responses — especially **error
responses** — against live Lemmy and PieFed instances: same status, same
body key-set, same machine-readable code, and the identical
`ResponseError` surfacing through a real `ThreadiverseClient` in both
cases.
- A weekly scheduled suite probes selected read-only error scenarios against
live Lemmy and PieFed instances, checking that real and fake clients surface
the same `ResponseError` subclass and, when the real provider exposes one,
the same HTTP status.

Maintainers can also run the authenticated acceptance suite against disposable
Voyager test accounts:

```sh
LIVE_AUTH=1 pnpm vitest run test/live-authenticated.test.ts
```

It reads the ignored project-root `.test-creds.json` by default;
`THREADIVERSE_TEST_CREDS` can select another file and
`THREADIVERSE_TEST_ACCOUNT` one account by key. With no account selector, it
exercises every configured account. Keep the file owner-only (`chmod 600
.test-creds.json`); the exact root filename is ignored by git. The existing
shared-instance shape remains supported:

```json
{
"instance": "lemmy.example",
"accounts": {
"test-user": "password"
}
}
```

To cover more than one provider in the same run, put the instance on each
account record (the record key is only a selector; `username` is sent to the
server):

```json
{
"accounts": {
"lemmy": {
"instance": "lemmy.example",
"username": "test-user",
"password": "password"
},
"piefed": {
"instance": "piefed.example",
"username": "test-user",
"password": "password"
}
}
}
```

Secrets are loaded only at runtime and failures are redacted. The suite has an
explicit endpoint requirement matrix for each provider mode and exercises
login, logout, and authenticated/person-scoped reads against the real APIs. It
does not make persistent content mutations.

Successful authentication and route existence are reported separately. Until
a PieFed test account is configured, safe unauthenticated probes still check
the real PieFed login/logout, notification-state, report, and community
ban/unban routes and HTTP methods: only expected `4xx` auth/validation
rejections pass; `404`, `405`, and `501` fail. The route-only run also performs
a successful public canonical profile-feed read. Run that coverage without any
credential file with:

```sh
LIVE_PIEFED_ROUTES=1 pnpm vitest run test/live-authenticated.test.ts
```

`THREADIVERSE_PIEFED_ROUTE_INSTANCE` overrides the default
`https://piefed.social` probe target.
Loading