diff --git a/.env.example b/.env.example index 87c4f19..dc94613 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,8 @@ TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite # Required when connecting Mastodon; keep this runtime file outside Git. # TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com -# Optional home Codex research prototype. Start pnpm codex:serve separately. -# TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500 +# Optional Codex research. The AI SDK provider manages its local app-server. +# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex # TWITTER_LITE_CODEX_MODEL=gpt-6-astra # TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research diff --git a/docs/plans/2026-09-24-home-agent-research.md b/docs/plans/2026-09-24-home-agent-research.md index bc78dc2..7ab8497 100644 --- a/docs/plans/2026-09-24-home-agent-research.md +++ b/docs/plans/2026-09-24-home-agent-research.md @@ -1,5 +1,8 @@ # Home-agent research +Historical design: the WebSocket transport and global active selection described +here were replaced by the [AI SDK research runtime](../research-runtime.md). + ## Product boundary The user starts a research task from the deck workspace. A home agent chooses diff --git a/docs/research-decks.md b/docs/research-decks.md index 0d09031..6ca9fa4 100644 --- a/docs/research-decks.md +++ b/docs/research-decks.md @@ -24,15 +24,15 @@ list, user-profile, and conversation pages are not provided. unique column IDs within each deck. Each column contains `id`, `title`, `connectionId`, and a platform-specific `source`. -| Platform | Source kind | Conditions | -| --- | --- | --- | -| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` | -| Twitter | `user` | `target`: handle or X/Twitter profile URL | -| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL | -| Mastodon | `search` | `query`; results depend on the instance's search configuration | -| Mastodon | `user` | `target`: account handle or the connected instance's numeric account ID | -| Mastodon | `list` | `target`: numeric list ID for the connected account | -| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores | +| Platform | Source kind | Conditions | +| -------- | ----------- | ----------------------------------------------------------------------- | +| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` | +| Twitter | `user` | `target`: handle or X/Twitter profile URL | +| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL | +| Mastodon | `search` | `query`; results depend on the instance's search configuration | +| Mastodon | `user` | `target`: account handle or the connected instance's numeric account ID | +| Mastodon | `list` | `target`: numeric list ID for the connected account | +| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores | `connectionId` is the account binding, distinct from a deck's name. Twitter connections resolve to profiles discovered from the configured relay. @@ -76,9 +76,9 @@ in a transaction. A stale revision is rejected. Save failures remain visible and do not report unsaved edits as persisted. Saved decks refresh on focus and every five seconds while visible. Refresh is -held while an editor is open. Active selection is a local preference under -`twitter-lite-active-deck`; selecting a deck does not switch another device's -view. Deleting the active deck selects a remaining one. When none remain, the +held while an editor is open. The `deck` URL parameter selects the active view; +selecting a deck does not switch another device's view. Browser history restores +selection, and saved decks can be opened from a copied URL. Deleting the active deck selects a remaining one. When none remain, the app opens an empty temporary view. WebMCP-created views are temporary by default. Temporary copies of saved decks @@ -139,3 +139,10 @@ uses an isolated SQLite database and mock relay through real server functions to exercise shared decks across browser contexts, editing, persistence, pagination, and native WebMCP. Deterministic automated tests do not require live SNS credentials. + +## Conversation execution + +The [research runtime](research-runtime.md) uses AI SDK and the Codex app-server +provider. Conversations are selected by URL, with per-conversation background +execution and reconnectable SSE. Opening New chat or another saved conversation +does not stop an existing turn or change another browser's selection. diff --git a/docs/research-runtime.md b/docs/research-runtime.md new file mode 100644 index 0000000..1dee0f7 --- /dev/null +++ b/docs/research-runtime.md @@ -0,0 +1,54 @@ +# Research runtime + +Research uses Vercel AI SDK 7 `streamText` with +`ai-sdk-provider-codex-cli` 2.3's app-server provider. The provider owns its local +stdio child, thread resumption, protocol decoding, and abort handling. The app +owns accepted requests, account-scoped tools, snapshots, and report validation. +The separate WebSocket launcher is removed. + +## Tools + +The provider does not register AI SDK `tools` as Codex dynamic tools. Existing +research definitions and executors are exposed with `createSdkMcpServer` instead. +Validation, connected-account scope, request budgets, evidence tracking, and +read-only SNS behavior remain in `agent-tools.server.ts`. Provider execution is +one agent turn; there is no second AI SDK tool loop replaying those calls. +Resumed threads that already registered dynamic tools route their calls through +`onDynamicToolCall` to the same validated executor. + +## Lifecycle and selection + +The background runner is keyed by conversation ID. Acceptance saves the user +message before starting the model. A request ID identifies a new conversation +and deduplicates retries within the process. Overlapping turns of the same +conversation are rejected, while different conversations can run independently. + +The URL selects a conversation. GET status and SSE subscribe to that ID; +selecting history never starts or cancels a turn. Reconnection sends the latest +complete snapshot. Closing a browser or changing its URL only detaches a viewer. +Only Stop, the execution timeout, or a server-side failure aborts generation. +Authentication expiry or logout closes the viewer's stream. + +SQLite retains messages, thread IDs, generated decks, and citations. It no +longer stores a global active conversation pointer. Browser views cannot change +another browser's selection. After a backend restart, unfinished conversations +are marked interrupted and require an explicit follow-up. No turn is replayed +automatically. Reports remain files under the configured report root. + +## Streaming presentation + +The model stream uses `smoothStream` with 15 ms pacing and the supplied chunking +pattern `/[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/`. The trailing buffer flushes at the +text-end event. The existing snapshot SSE remains the UI's reconnectable data +channel; an HTTP request's lifetime never owns the model execution. + +Streamdown displays partial Markdown and animates only the latest live answer. +Completed messages render immediately. Reduced-motion preferences disable the +animation. HTML is skipped, unsafe link protocols are rejected, images remain +links, and retrieved post citations keep their deck navigation behavior. +`@shadcn/helpers/ai-sdk` provides deterministic streaming fixtures for UI tests. + +References: [Codex provider](https://github.com/ben-vargas/ai-sdk-provider-codex-cli), +[AI SDK smoothing](https://ai-sdk.dev/docs/reference/ai-sdk-core/smooth-stream), +[Streamdown animation](https://streamdown.ai/docs/animation), +[shadcn AI SDK helpers](https://ui.shadcn.com/docs/helpers/ai-sdk). diff --git a/docs/vitals-preview.md b/docs/vitals-preview.md index 6515db4..916ee2a 100644 --- a/docs/vitals-preview.md +++ b/docs/vitals-preview.md @@ -16,3 +16,7 @@ as the anatomy illustration source and clipped in CSS. Source media: https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg The rest of the interface is HTML/CSS with SVG data charts. Reference-specific colors are scoped to `--vital-*` tokens in `vitals.css`. + +Sections, tabs, history, chart filters, and detail dialogs are URL state and +restore on reload or browser Back/Forward. Simulated climate control values +remain local; opening a URL never issues a device command. diff --git a/docs/webmcp-prototype.md b/docs/webmcp-prototype.md index 5870f37..f484cf0 100644 --- a/docs/webmcp-prototype.md +++ b/docs/webmcp-prototype.md @@ -13,15 +13,15 @@ operations use the same access controls and persistence rules as the UI. ## Workspace tools -| Tool | Input | Behavior | -| --- | --- | --- | -| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials | -| `list_decks` | `{}` | Return saved decks and this tab's temporary views, `activeDeckId`, and `storageError` | -| `get_deck` | Optional `deckId` | Read the named or active view, including `persisted` and saved `revision` | -| `set_deck` | Optional `deckId` and `expectedRevision`, required `title` and `columns` | Without an ID, create a temporary view; with an ID, replace and activate that existing view | -| `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged | -| `select_deck` | `deckId` | Activate a view; selection remains device-local | -| `delete_deck` | `deckId`, optional `expectedRevision` | Discard a temporary view or delete a saved deck for all devices | +| Tool | Input | Behavior | +| ------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials | +| `list_decks` | `{}` | Return saved decks and this tab's temporary views, `activeDeckId`, and `storageError` | +| `get_deck` | Optional `deckId` | Read the named or active view, including `persisted` and saved `revision` | +| `set_deck` | Optional `deckId` and `expectedRevision`, required `title` and `columns` | Without an ID, create a temporary view; with an ID, replace and activate that existing view | +| `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged | +| `select_deck` | `deckId` | Activate a view and update the URL; other tabs remain unchanged | +| `delete_deck` | `deckId`, optional `expectedRevision` | Discard a temporary view or delete a saved deck for all devices | Read tools return the workspace's current client snapshot, not a fresh server request. Saved definitions refresh on focus and while visible, except during diff --git a/drizzle/0005_salty_madripoor.sql b/drizzle/0005_salty_madripoor.sql new file mode 100644 index 0000000..6da2112 --- /dev/null +++ b/drizzle/0005_salty_madripoor.sql @@ -0,0 +1 @@ +DROP TABLE `research_state`; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..c149a39 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,657 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7a44ef34-89ad-4ab4-829d-f6669bd72471", + "prevId": "52f68a9d-b9da-4739-9635-b0b5e3e8040b", + "tables": { + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_owner_id_workspace_owner_id_fk": { + "name": "auth_sessions_owner_id_workspace_owner_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "workspace_owner", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_throttle": { + "name": "auth_throttle", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reset_at": { + "name": "reset_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "auth_throttle_singleton": { + "name": "auth_throttle_singleton", + "value": "\"auth_throttle\".\"id\" = 1" + } + } + }, + "connection_credentials": { + "name": "connection_credentials", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "connection_credentials_connection_id_connections_id_fk": { + "name": "connection_credentials_connection_id_connections_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_credentials_app_id_oauth_apps_id_fk": { + "name": "connection_credentials_app_id_oauth_apps_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connections": { + "name": "connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "relay_profile": { + "name": "relay_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connections_account": { + "name": "connections_account", + "columns": ["platform", "origin", "account_id"], + "isUnique": true + }, + "connections_relay_profile": { + "name": "connections_relay_profile", + "columns": ["origin", "relay_profile"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deck_columns": { + "name": "deck_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deck_columns_position": { + "name": "deck_columns_position", + "columns": ["deck_id", "position"], + "isUnique": true + } + }, + "foreignKeys": { + "deck_columns_deck_id_decks_id_fk": { + "name": "deck_columns_deck_id_decks_id_fk", + "tableFrom": "deck_columns", + "tableTo": "decks", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_columns_connection_id_connections_id_fk": { + "name": "deck_columns_connection_id_connections_id_fk", + "tableFrom": "deck_columns", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_columns_deck_id_id_pk": { + "columns": ["deck_id", "id"], + "name": "deck_columns_deck_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "deck_columns_valid_position": { + "name": "deck_columns_valid_position", + "value": "\"deck_columns\".\"position\" >= 0" + } + } + }, + "decks": { + "name": "decks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "decks_positive_revision": { + "name": "decks_positive_revision", + "value": "\"decks\".\"revision\" >= 1" + } + } + }, + "legacy_imports": { + "name": "legacy_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_ids": { + "name": "deck_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_apps": { + "name": "oauth_apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_apps_configuration": { + "name": "oauth_apps_configuration", + "columns": ["origin", "redirect_uri", "scopes"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_attempts": { + "name": "oauth_attempts", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_verifier": { + "name": "encrypted_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_attempts_app_id_oauth_apps_id_fk": { + "name": "oauth_attempts_app_id_oauth_apps_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_attempts_connection_id_connections_id_fk": { + "name": "oauth_attempts_connection_id_connections_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "research_sessions": { + "name": "research_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_owner": { + "name": "workspace_owner", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "workspace_owner_singleton": { + "name": "workspace_owner_singleton", + "value": "\"workspace_owner\".\"id\" = 1" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2e1ed50..329b7ef 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1790589494499, "tag": "0004_good_natasha_romanoff", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1790591720807, + "tag": "0005_salty_madripoor", + "breakpoints": true } ] } diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index f0425d7..f27cd53 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,89 +1,88 @@ -import { basename, dirname, isAbsolute } from 'node:path' -import AxeBuilder from '@axe-core/playwright' -import { test as base } from '@playwright/test' +import { basename, dirname, isAbsolute } from "node:path"; +import AxeBuilder from "@axe-core/playwright"; +import { test as base } from "@playwright/test"; import { completeOnboarding, createOwner, readAuthState, signIn, -} from '../src/features/auth/auth.server' -import { openDatabase } from '../src/features/storage/database.server' +} from "../src/features/auth/auth.server"; +import { openDatabase } from "../src/features/storage/database.server"; export const test = base.extend<{ - a11y: () => AxeBuilder - resetDecks: undefined - sessionToken: string + a11y: () => AxeBuilder; + resetDecks: undefined; + sessionToken: string; }>({ - // biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. + // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments. sessionToken: async ({}, use) => { - const path = process.env.TWITTER_LITE_E2E_DB_PATH - if (!path || !basename(dirname(path)).startsWith('twitter-lite-e2e-')) - throw new Error('Isolated E2E database is required.') - const database = openDatabase(path) + const path = process.env.TWITTER_LITE_E2E_DB_PATH; + if (!path || !basename(dirname(path)).startsWith("twitter-lite-e2e-")) + throw new Error("Isolated E2E database is required."); + const database = openDatabase(path); const credentials = { - email: 'owner@workspace.invalid', - password: 'E2E-only-passphrase-2026', - } - process.env.WORKSPACE_SETUP_TOKEN = - 'isolated-e2e-setup-token-not-for-production' + email: "owner@workspace.invalid", + password: "E2E-only-passphrase-2026", + }; + process.env.WORKSPACE_SETUP_TOKEN = "isolated-e2e-setup-token-not-for-production"; const result = readAuthState(database).needsSetup ? await createOwner( { ...credentials, - name: 'Yuta', + name: "Yuta", setupToken: process.env.WORKSPACE_SETUP_TOKEN, }, database, ) - : await signIn(credentials, database) + : await signIn(credentials, database); if (!result.owner.onboardingCompletedAt) - completeOnboarding(result.sessionToken, { name: 'Yuta' }, database) - database.$client.close() - await use(result.sessionToken) + completeOnboarding(result.sessionToken, { name: "Yuta" }, database); + database.$client.close(); + await use(result.sessionToken); }, storageState: async ({ sessionToken }, use) => { await use({ cookies: [ { - name: 'workspace_session', + name: "workspace_session", value: sessionToken, - domain: '127.0.0.1', - path: '/', + domain: "127.0.0.1", + path: "/", expires: Math.floor(Date.now() / 1000) + 3600, httpOnly: true, secure: false, - sameSite: 'Lax', + sameSite: "Lax", }, ], origins: [], - }) + }); }, resetDecks: [ - // biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. + // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments. async ({}, use) => { - const path = process.env.TWITTER_LITE_E2E_DB_PATH + const path = process.env.TWITTER_LITE_E2E_DB_PATH; if ( !path || !isAbsolute(path) || - !basename(dirname(path)).startsWith('twitter-lite-e2e-') || - basename(path) !== 'workspace.sqlite' + !basename(dirname(path)).startsWith("twitter-lite-e2e-") || + basename(path) !== "workspace.sqlite" ) - throw new Error('Isolated E2E database is required.') - const database = openDatabase(path).$client - database.pragma('foreign_keys = ON') - database.exec('DELETE FROM decks; DELETE FROM legacy_imports;') - database.close() - await use(undefined) + throw new Error("Isolated E2E database is required."); + const database = openDatabase(path).$client; + database.pragma("foreign_keys = ON"); + database.exec("DELETE FROM decks; DELETE FROM legacy_imports;"); + database.close(); + await use(undefined); }, { auto: true }, ], a11y: async ({ page }, use) => { await use(() => new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .disableRules(['meta-viewport']), - ) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .disableRules(["meta-viewport"]), + ); }, -}) +}); -export { expect } from '@playwright/test' +export { expect } from "@playwright/test"; diff --git a/e2e/fixtures/twitter.ts b/e2e/fixtures/twitter.ts index 99e4e4d..827315a 100644 --- a/e2e/fixtures/twitter.ts +++ b/e2e/fixtures/twitter.ts @@ -1,24 +1,20 @@ -import type { TweetLegacy } from '../generated/twitter-openapi/client.ts' +import type { TweetLegacy } from "../generated/twitter-openapi/client.ts"; interface TweetLegacyFixtureOptions { - conversationId?: string - inReplyTo?: string - replyCount?: number + conversationId?: string; + inReplyTo?: string; + replyCount?: number; } export const tweetLegacyFixture = ( id: string, text: string, - { - conversationId = id, - inReplyTo, - replyCount = 1, - }: TweetLegacyFixtureOptions = {}, + { conversationId = id, inReplyTo, replyCount = 1 }: TweetLegacyFixtureOptions = {}, ): TweetLegacy => ({ bookmark_count: 0, bookmarked: false, conversation_id_str: conversationId, - created_at: 'Mon Jul 13 00:00:00 +0000 2026', + created_at: "Mon Jul 13 00:00:00 +0000 2026", display_text_range: [0, text.length], favorite_count: 3, favorited: false, @@ -26,10 +22,10 @@ export const tweetLegacyFixture = ( id_str: id, ...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}), is_quote_status: false, - lang: 'ja', + lang: "ja", quote_count: 0, reply_count: replyCount, retweet_count: 2, retweeted: false, - user_id_str: '42', -}) + user_id_str: "42", +}); diff --git a/e2e/generated/twitter-openapi/client.ts b/e2e/generated/twitter-openapi/client.ts index 6b805f7..5b94601 100644 --- a/e2e/generated/twitter-openapi/client.ts +++ b/e2e/generated/twitter-openapi/client.ts @@ -5,1725 +5,1703 @@ * Twitter OpenAPI(Swagger) specification * OpenAPI spec version: 0.0.1 */ -export type TypeName = (typeof TypeName)[keyof typeof TypeName] +export type TypeName = (typeof TypeName)[keyof typeof TypeName]; export const TypeName = { - TimelineTweet: 'TimelineTweet', - TimelineTimelineItem: 'TimelineTimelineItem', - TimelineUser: 'TimelineUser', - TimelineTimelineCursor: 'TimelineTimelineCursor', - TweetWithVisibilityResults: 'TweetWithVisibilityResults', - ContextualTweetInterstitial: 'ContextualTweetInterstitial', - TimelineTimelineModule: 'TimelineTimelineModule', - TweetTombstone: 'TweetTombstone', - TimelinePrompt: 'TimelinePrompt', - TimelineMessagePrompt: 'TimelineMessagePrompt', - TimelineCommunity: 'TimelineCommunity', - TimelineTombstone: 'TimelineTombstone', - TimelineTrend: 'TimelineTrend', - TimelineNotification: 'TimelineNotification', - TimelineNotificationAggregateUserActions: - 'TimelineNotificationAggregateUserActions', - TweetUnavailable: 'TweetUnavailable', - TweetPreviewDisplay: 'TweetPreviewDisplay', - Tweet: 'Tweet', - User: 'User', - UserUnavailable: 'UserUnavailable', - Community: 'Community', - CommunityDeleteActionUnavailable: 'CommunityDeleteActionUnavailable', - CommunityJoinAction: 'CommunityJoinAction', - CommunityJoinActionUnavailable: 'CommunityJoinActionUnavailable', - CommunityLeaveActionUnavailable: 'CommunityLeaveActionUnavailable', - CommunityTweetPinActionUnavailable: 'CommunityTweetPinActionUnavailable', - CommunityTweetUnpinActionUnavailable: 'CommunityTweetUnpinActionUnavailable', - CommunityInvitesUnavailable: 'CommunityInvitesUnavailable', - CommunityJoinRequestsUnavailable: 'CommunityJoinRequestsUnavailable', - CommunityUnavailable: 'CommunityUnavailable', - ApiImage: 'ApiImage', - TimelineRichTextUser: 'TimelineRichTextUser', -} as const + TimelineTweet: "TimelineTweet", + TimelineTimelineItem: "TimelineTimelineItem", + TimelineUser: "TimelineUser", + TimelineTimelineCursor: "TimelineTimelineCursor", + TweetWithVisibilityResults: "TweetWithVisibilityResults", + ContextualTweetInterstitial: "ContextualTweetInterstitial", + TimelineTimelineModule: "TimelineTimelineModule", + TweetTombstone: "TweetTombstone", + TimelinePrompt: "TimelinePrompt", + TimelineMessagePrompt: "TimelineMessagePrompt", + TimelineCommunity: "TimelineCommunity", + TimelineTombstone: "TimelineTombstone", + TimelineTrend: "TimelineTrend", + TimelineNotification: "TimelineNotification", + TimelineNotificationAggregateUserActions: "TimelineNotificationAggregateUserActions", + TweetUnavailable: "TweetUnavailable", + TweetPreviewDisplay: "TweetPreviewDisplay", + Tweet: "Tweet", + User: "User", + UserUnavailable: "UserUnavailable", + Community: "Community", + CommunityDeleteActionUnavailable: "CommunityDeleteActionUnavailable", + CommunityJoinAction: "CommunityJoinAction", + CommunityJoinActionUnavailable: "CommunityJoinActionUnavailable", + CommunityLeaveActionUnavailable: "CommunityLeaveActionUnavailable", + CommunityTweetPinActionUnavailable: "CommunityTweetPinActionUnavailable", + CommunityTweetUnpinActionUnavailable: "CommunityTweetUnpinActionUnavailable", + CommunityInvitesUnavailable: "CommunityInvitesUnavailable", + CommunityJoinRequestsUnavailable: "CommunityJoinRequestsUnavailable", + CommunityUnavailable: "CommunityUnavailable", + ApiImage: "ApiImage", + TimelineRichTextUser: "TimelineRichTextUser", +} as const; -export type ClientEventInfoDetails = { [key: string]: unknown } +export type ClientEventInfoDetails = { [key: string]: unknown }; export interface ClientEventInfo { - component?: string - details?: ClientEventInfoDetails - element?: string + component?: string; + details?: ClientEventInfoDetails; + element?: string; } -export type ContentEntryType = - (typeof ContentEntryType)[keyof typeof ContentEntryType] +export type ContentEntryType = (typeof ContentEntryType)[keyof typeof ContentEntryType]; export const ContentEntryType = { - TimelineTimelineItem: 'TimelineTimelineItem', - TimelineTimelineCursor: 'TimelineTimelineCursor', - TimelineTimelineModule: 'TimelineTimelineModule', -} as const + TimelineTimelineItem: "TimelineTimelineItem", + TimelineTimelineCursor: "TimelineTimelineCursor", + TimelineTimelineModule: "TimelineTimelineModule", +} as const; export interface TextHighlight { - endIndex: number - startIndex: number + endIndex: number; + startIndex: number; } export interface Highlight { - textHighlights: TextHighlight[] + textHighlights: TextHighlight[]; } -export type ContentItemType = - (typeof ContentItemType)[keyof typeof ContentItemType] +export type ContentItemType = (typeof ContentItemType)[keyof typeof ContentItemType]; export const ContentItemType = { - TimelineTweet: 'TimelineTweet', - TimelineTimelineCursor: 'TimelineTimelineCursor', - TimelineUser: 'TimelineUser', - TimelinePrompt: 'TimelinePrompt', - TimelineMessagePrompt: 'TimelineMessagePrompt', - TimelineCommunity: 'TimelineCommunity', - TimelineTombstone: 'TimelineTombstone', - TimelineTrend: 'TimelineTrend', - TimelineNotification: 'TimelineNotification', -} as const + TimelineTweet: "TimelineTweet", + TimelineTimelineCursor: "TimelineTimelineCursor", + TimelineUser: "TimelineUser", + TimelinePrompt: "TimelinePrompt", + TimelineMessagePrompt: "TimelineMessagePrompt", + TimelineCommunity: "TimelineCommunity", + TimelineTombstone: "TimelineTombstone", + TimelineTrend: "TimelineTrend", + TimelineNotification: "TimelineNotification", +} as const; export type TimelineGeneralContextContextType = - (typeof TimelineGeneralContextContextType)[keyof typeof TimelineGeneralContextContextType] + (typeof TimelineGeneralContextContextType)[keyof typeof TimelineGeneralContextContextType]; export const TimelineGeneralContextContextType = { - Follow: 'Follow', - Pin: 'Pin', - Like: 'Like', - Location: 'Location', - Sparkle: 'Sparkle', - Conversation: 'Conversation', - List: 'List', - Community: 'Community', - Facepile: 'Facepile', -} as const + Follow: "Follow", + Pin: "Pin", + Like: "Like", + Location: "Location", + Sparkle: "Sparkle", + Conversation: "Conversation", + List: "List", + Community: "Community", + Facepile: "Facepile", +} as const; export type SocialContextLandingUrlUrlType = - (typeof SocialContextLandingUrlUrlType)[keyof typeof SocialContextLandingUrlUrlType] + (typeof SocialContextLandingUrlUrlType)[keyof typeof SocialContextLandingUrlUrlType]; export const SocialContextLandingUrlUrlType = { - DeepLink: 'DeepLink', - UrtEndpoint: 'UrtEndpoint', - ExternalUrl: 'ExternalUrl', -} as const + DeepLink: "DeepLink", + UrtEndpoint: "UrtEndpoint", + ExternalUrl: "ExternalUrl", +} as const; export interface UrtEndpointRequestParams { - key: string - value: string + key: string; + value: string; } export interface UrtEndpointOptions { - requestParams: UrtEndpointRequestParams[] - title: string + requestParams: UrtEndpointRequestParams[]; + title: string; } export interface SocialContextLandingUrl { - url?: string - urlType?: SocialContextLandingUrlUrlType - urtEndpointOptions?: UrtEndpointOptions + url?: string; + urlType?: SocialContextLandingUrlUrlType; + urtEndpointOptions?: UrtEndpointOptions; } export type SocialContextUnionType = - (typeof SocialContextUnionType)[keyof typeof SocialContextUnionType] + (typeof SocialContextUnionType)[keyof typeof SocialContextUnionType]; export const SocialContextUnionType = { - TimelineGeneralContext: 'TimelineGeneralContext', - TimelineTopicContext: 'TimelineTopicContext', -} as const + TimelineGeneralContext: "TimelineGeneralContext", + TimelineTopicContext: "TimelineTopicContext", +} as const; export interface TimelineGeneralContext { - contextImageUrls?: string[] - contextType?: TimelineGeneralContextContextType - landingUrl?: SocialContextLandingUrl - text?: string - type?: SocialContextUnionType + contextImageUrls?: string[]; + contextType?: TimelineGeneralContextContextType; + landingUrl?: SocialContextLandingUrl; + text?: string; + type?: SocialContextUnionType; } export type TimelineTopicContextFunctionalityType = - (typeof TimelineTopicContextFunctionalityType)[keyof typeof TimelineTopicContextFunctionalityType] + (typeof TimelineTopicContextFunctionalityType)[keyof typeof TimelineTopicContextFunctionalityType]; export const TimelineTopicContextFunctionalityType = { - Basic: 'Basic', -} as const + Basic: "Basic", +} as const; export interface TopicContext { - description?: string - following?: boolean - icon_url?: string - id?: string - name?: string - not_interested?: boolean - topic_id?: string + description?: string; + following?: boolean; + icon_url?: string; + id?: string; + name?: string; + not_interested?: boolean; + topic_id?: string; } export interface TimelineTopicContext { - functionalityType?: TimelineTopicContextFunctionalityType - topic?: TopicContext - type?: SocialContextUnionType + functionalityType?: TimelineTopicContextFunctionalityType; + topic?: TopicContext; + type?: SocialContextUnionType; } -export type SocialContextUnion = TimelineGeneralContext | TimelineTopicContext +export type SocialContextUnion = TimelineGeneralContext | TimelineTopicContext; export type TimelineTweetTweetDisplayType = - (typeof TimelineTweetTweetDisplayType)[keyof typeof TimelineTweetTweetDisplayType] + (typeof TimelineTweetTweetDisplayType)[keyof typeof TimelineTweetTweetDisplayType]; export const TimelineTweetTweetDisplayType = { - Tweet: 'Tweet', - SelfThread: 'SelfThread', - MediaGrid: 'MediaGrid', - CondensedTweet: 'CondensedTweet', -} as const + Tweet: "Tweet", + SelfThread: "SelfThread", + MediaGrid: "MediaGrid", + CondensedTweet: "CondensedTweet", +} as const; export interface ArticleCoverMediaColorInfoPaletteRGB { - blue: number - green: number - red: number + blue: number; + green: number; + red: number; } export interface ArticleCoverMediaColorInfoPalette { - percentage: number - rgb: ArticleCoverMediaColorInfoPaletteRGB + percentage: number; + rgb: ArticleCoverMediaColorInfoPaletteRGB; } export interface ArticleCoverMediaColorInfo { - palette: ArticleCoverMediaColorInfoPalette[] + palette: ArticleCoverMediaColorInfoPalette[]; } export interface ArticleCoverMediaInfo { - __typename?: TypeName - color_info: ArticleCoverMediaColorInfo - original_img_height: number - original_img_url: string - original_img_width: number + __typename?: TypeName; + color_info: ArticleCoverMediaColorInfo; + original_img_height: number; + original_img_url: string; + original_img_width: number; } export interface ArticleCoverMedia { - id: string + id: string; /** @pattern ^[0-9]+$ */ - media_id: string - media_info: ArticleCoverMediaInfo - media_key: string + media_id: string; + media_info: ArticleCoverMediaInfo; + media_key: string; } export interface ArticleLifecycleState { - modified_at_secs: number + modified_at_secs: number; } export interface ArticleMetadata { - first_published_at_secs: number + first_published_at_secs: number; } -export type ArticleResultContentState = { [key: string]: unknown } +export type ArticleResultContentState = { [key: string]: unknown }; export interface ArticleResult { - content_state?: ArticleResultContentState - cover_media?: ArticleCoverMedia - id: string - is_grok_summary_eligible?: boolean - lifecycle_state?: ArticleLifecycleState - media_entities?: ArticleCoverMedia[] - metadata: ArticleMetadata - preview_text: string + content_state?: ArticleResultContentState; + cover_media?: ArticleCoverMedia; + id: string; + is_grok_summary_eligible?: boolean; + lifecycle_state?: ArticleLifecycleState; + media_entities?: ArticleCoverMedia[]; + metadata: ArticleMetadata; + preview_text: string; /** @pattern ^[0-9]+$ */ - rest_id: string - title: string + rest_id: string; + title: string; } export interface ArticleResults { - result: ArticleResult + result: ArticleResult; } export interface Article { - article_results: ArticleResults + article_results: ArticleResults; } export type CommunityDeleteActionResultReason = - (typeof CommunityDeleteActionResultReason)[keyof typeof CommunityDeleteActionResultReason] + (typeof CommunityDeleteActionResultReason)[keyof typeof CommunityDeleteActionResultReason]; export const CommunityDeleteActionResultReason = { - Unavailable: 'Unavailable', -} as const + Unavailable: "Unavailable", +} as const; export interface CommunityDeleteActionResult { - __typename: TypeName - reason: CommunityDeleteActionResultReason + __typename: TypeName; + reason: CommunityDeleteActionResultReason; } export type _CommunityJoinActionTypename = - (typeof _CommunityJoinActionTypename)[keyof typeof _CommunityJoinActionTypename] + (typeof _CommunityJoinActionTypename)[keyof typeof _CommunityJoinActionTypename]; export const _CommunityJoinActionTypename = { - CommunityJoinAction: 'CommunityJoinAction', -} as const + CommunityJoinAction: "CommunityJoinAction", +} as const; export interface CommunityJoinAction { - __typename: _CommunityJoinActionTypename + __typename: _CommunityJoinActionTypename; } export type _CommunityJoinActionUnavailableTypename = - (typeof _CommunityJoinActionUnavailableTypename)[keyof typeof _CommunityJoinActionUnavailableTypename] + (typeof _CommunityJoinActionUnavailableTypename)[keyof typeof _CommunityJoinActionUnavailableTypename]; export const _CommunityJoinActionUnavailableTypename = { - CommunityJoinActionUnavailable: 'CommunityJoinActionUnavailable', -} as const + CommunityJoinActionUnavailable: "CommunityJoinActionUnavailable", +} as const; export type CommunityJoinActionUnavailableReason = - (typeof CommunityJoinActionUnavailableReason)[keyof typeof CommunityJoinActionUnavailableReason] + (typeof CommunityJoinActionUnavailableReason)[keyof typeof CommunityJoinActionUnavailableReason]; export const CommunityJoinActionUnavailableReason = { - ViewerRequestRequired: 'ViewerRequestRequired', -} as const + ViewerRequestRequired: "ViewerRequestRequired", +} as const; export interface CommunityJoinActionUnavailable { - __typename: _CommunityJoinActionUnavailableTypename - message: string - reason: CommunityJoinActionUnavailableReason + __typename: _CommunityJoinActionUnavailableTypename; + message: string; + reason: CommunityJoinActionUnavailableReason; } -export type CommunityJoinActionResultUnion = - | CommunityJoinAction - | CommunityJoinActionUnavailable +export type CommunityJoinActionResultUnion = CommunityJoinAction | CommunityJoinActionUnavailable; export type CommunityLeaveActionResultReason = - (typeof CommunityLeaveActionResultReason)[keyof typeof CommunityLeaveActionResultReason] + (typeof CommunityLeaveActionResultReason)[keyof typeof CommunityLeaveActionResultReason]; export const CommunityLeaveActionResultReason = { - ViewerNotMember: 'ViewerNotMember', -} as const + ViewerNotMember: "ViewerNotMember", +} as const; export interface CommunityLeaveActionResult { - __typename: TypeName - message: string - reason: CommunityLeaveActionResultReason + __typename: TypeName; + message: string; + reason: CommunityLeaveActionResultReason; } export interface CommunityPinActionResult { - __typename: TypeName + __typename: TypeName; } export interface CommunityUnpinActionResult { - __typename: TypeName + __typename: TypeName; } export interface CommunityActions { - delete_action_result?: CommunityDeleteActionResult - join_action_result?: CommunityJoinActionResultUnion - leave_action_result?: CommunityLeaveActionResult - pin_action_result?: CommunityPinActionResult - unpin_action_result?: CommunityUnpinActionResult + delete_action_result?: CommunityDeleteActionResult; + join_action_result?: CommunityJoinActionResultUnion; + leave_action_result?: CommunityLeaveActionResult; + pin_action_result?: CommunityPinActionResult; + unpin_action_result?: CommunityUnpinActionResult; } export interface UserAvatar { - image_url: string + image_url: string; } /** * @pattern ^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (0[1-9]|[12][0-9]|3[01]) (0[0-9]|1[0-9]|2[0-3])(: ?)([0-5][0-9])(: ?)([0-5][0-9]) ([+-][0-9]{4}) ([0-9]{4})$ */ -export type TwitterTimeFormat = string +export type TwitterTimeFormat = string; export interface UserCore { - created_at: TwitterTimeFormat - name: string - screen_name: string + created_at: TwitterTimeFormat; + name: string; + screen_name: string; } export interface UserDmPermissions { - can_dm: boolean + can_dm: boolean; } export interface UserHighlightsInfo { - can_highlight_tweets: boolean - highlighted_tweets: string + can_highlight_tweets: boolean; + highlighted_tweets: string; } export type UserLegacyVerifiedType = - (typeof UserLegacyVerifiedType)[keyof typeof UserLegacyVerifiedType] + (typeof UserLegacyVerifiedType)[keyof typeof UserLegacyVerifiedType]; export const UserLegacyVerifiedType = { - Business: 'Business', - Government: 'Government', -} as const + Business: "Business", + Government: "Government", +} as const; -export type UserLegacyEntities = { [key: string]: unknown } +export type UserLegacyEntities = { [key: string]: unknown }; -export type UserLegacyProfileBannerExtensions = { [key: string]: unknown } +export type UserLegacyProfileBannerExtensions = { [key: string]: unknown }; -export type UserLegacyProfileImageExtensions = { [key: string]: unknown } +export type UserLegacyProfileImageExtensions = { [key: string]: unknown }; export interface UserLegacy { - blocked_by?: boolean - blocking?: boolean - can_dm?: boolean - can_media_tag?: boolean - created_at?: TwitterTimeFormat - default_profile: boolean - default_profile_image: boolean - description: string - entities: UserLegacyEntities - fast_followers_count: number - favourites_count: number - follow_request_sent?: boolean - followed_by?: boolean - followers_count: number - following?: boolean - friends_count: number - has_custom_timelines: boolean - is_translator: boolean - listed_count: number - location?: string - media_count: number - muting?: boolean - name?: string - normal_followers_count: number - notifications?: boolean - pinned_tweet_ids_str?: string[] - possibly_sensitive: boolean - profile_banner_extensions?: UserLegacyProfileBannerExtensions - profile_banner_url?: string - profile_image_extensions?: UserLegacyProfileImageExtensions - profile_image_url_https?: string - profile_interstitial_type: string - protected?: boolean - screen_name?: string - statuses_count: number - time_zone: string - translator_type: string - url?: string - utc_offset: number - verified?: boolean - verified_type?: UserLegacyVerifiedType - want_retweets?: boolean - withheld_description: string - withheld_in_countries?: string[] - withheld_scope: string + blocked_by?: boolean; + blocking?: boolean; + can_dm?: boolean; + can_media_tag?: boolean; + created_at?: TwitterTimeFormat; + default_profile: boolean; + default_profile_image: boolean; + description: string; + entities: UserLegacyEntities; + fast_followers_count: number; + favourites_count: number; + follow_request_sent?: boolean; + followed_by?: boolean; + followers_count: number; + following?: boolean; + friends_count: number; + has_custom_timelines: boolean; + is_translator: boolean; + listed_count: number; + location?: string; + media_count: number; + muting?: boolean; + name?: string; + normal_followers_count: number; + notifications?: boolean; + pinned_tweet_ids_str?: string[]; + possibly_sensitive: boolean; + profile_banner_extensions?: UserLegacyProfileBannerExtensions; + profile_banner_url?: string; + profile_image_extensions?: UserLegacyProfileImageExtensions; + profile_image_url_https?: string; + profile_interstitial_type: string; + protected?: boolean; + screen_name?: string; + statuses_count: number; + time_zone: string; + translator_type: string; + url?: string; + utc_offset: number; + verified?: boolean; + verified_type?: UserLegacyVerifiedType; + want_retweets?: boolean; + withheld_description: string; + withheld_in_countries?: string[]; + withheld_scope: string; } export type UserLegacyExtendedProfileBirthdateVisibility = - (typeof UserLegacyExtendedProfileBirthdateVisibility)[keyof typeof UserLegacyExtendedProfileBirthdateVisibility] + (typeof UserLegacyExtendedProfileBirthdateVisibility)[keyof typeof UserLegacyExtendedProfileBirthdateVisibility]; export const UserLegacyExtendedProfileBirthdateVisibility = { - Self: 'Self', - Public: 'Public', - MutualFollow: 'MutualFollow', - Followers: 'Followers', - Following: 'Following', -} as const + Self: "Self", + Public: "Public", + MutualFollow: "MutualFollow", + Followers: "Followers", + Following: "Following", +} as const; export type UserLegacyExtendedProfileBirthdateYearVisibility = - (typeof UserLegacyExtendedProfileBirthdateYearVisibility)[keyof typeof UserLegacyExtendedProfileBirthdateYearVisibility] + (typeof UserLegacyExtendedProfileBirthdateYearVisibility)[keyof typeof UserLegacyExtendedProfileBirthdateYearVisibility]; export const UserLegacyExtendedProfileBirthdateYearVisibility = { - Self: 'Self', - Public: 'Public', - MutualFollow: 'MutualFollow', - Followers: 'Followers', - Following: 'Following', -} as const + Self: "Self", + Public: "Public", + MutualFollow: "MutualFollow", + Followers: "Followers", + Following: "Following", +} as const; export interface UserLegacyExtendedProfileBirthdate { - day: number - month: number - visibility: UserLegacyExtendedProfileBirthdateVisibility - year?: number - year_visibility: UserLegacyExtendedProfileBirthdateYearVisibility + day: number; + month: number; + visibility: UserLegacyExtendedProfileBirthdateVisibility; + year?: number; + year_visibility: UserLegacyExtendedProfileBirthdateYearVisibility; } export interface UserLegacyExtendedProfile { - birthdate?: UserLegacyExtendedProfileBirthdate + birthdate?: UserLegacyExtendedProfileBirthdate; } export interface UserLocation { - location: string + location: string; } export interface UserMediaPermissions { - can_media_tag: boolean + can_media_tag: boolean; } export type UserParodyCommentaryFanLabel = - (typeof UserParodyCommentaryFanLabel)[keyof typeof UserParodyCommentaryFanLabel] + (typeof UserParodyCommentaryFanLabel)[keyof typeof UserParodyCommentaryFanLabel]; export const UserParodyCommentaryFanLabel = { - None: 'None', - Parody: 'Parody', - Commentary: 'Commentary', - Fan: 'Fan', -} as const + None: "None", + Parody: "Parody", + Commentary: "Commentary", + Fan: "Fan", +} as const; export interface UserPrivacy { - protected: boolean + protected: boolean; } export interface UserProfessionalCategory { - icon_name: string - id: number - name: string + icon_name: string; + id: number; + name: string; } export type UserProfessionalProfessionalType = - (typeof UserProfessionalProfessionalType)[keyof typeof UserProfessionalProfessionalType] + (typeof UserProfessionalProfessionalType)[keyof typeof UserProfessionalProfessionalType]; export const UserProfessionalProfessionalType = { - Business: 'Business', - Creator: 'Creator', -} as const + Business: "Business", + Creator: "Creator", +} as const; export interface UserProfessional { - category?: UserProfessionalCategory[] - professional_type: UserProfessionalProfessionalType + category?: UserProfessionalCategory[]; + professional_type: UserProfessionalProfessionalType; /** @pattern ^[0-9]+$ */ - rest_id: string + rest_id: string; } export interface ProfileBio { - description: string + description: string; } export type UserProfileImageShape = - (typeof UserProfileImageShape)[keyof typeof UserProfileImageShape] + (typeof UserProfileImageShape)[keyof typeof UserProfileImageShape]; export const UserProfileImageShape = { - Circle: 'Circle', - Square: 'Square', - Hexagon: 'Hexagon', -} as const + Circle: "Circle", + Square: "Square", + Hexagon: "Hexagon", +} as const; export interface UserRelationshipPerspectives { - blocked_by?: boolean - blocking?: boolean - followed_by?: boolean - following?: boolean + blocked_by?: boolean; + blocking?: boolean; + followed_by?: boolean; + following?: boolean; } export interface SuperFollowUserProfile { - is_active: boolean + is_active: boolean; } export interface UserProfile { - super_follows_user_profile: SuperFollowUserProfile + super_follows_user_profile: SuperFollowUserProfile; } export interface UserTipJarSettings { - bandcamp_handle?: string - bitcoin_handle?: string - cash_app_handle?: string - ethereum_handle?: string - gofundme_handle?: string - is_enabled?: boolean - patreon_handle?: string - pay_pal_handle?: string - venmo_handle?: string + bandcamp_handle?: string; + bitcoin_handle?: string; + cash_app_handle?: string; + ethereum_handle?: string; + gofundme_handle?: string; + is_enabled?: boolean; + patreon_handle?: string; + pay_pal_handle?: string; + venmo_handle?: string; } export type UserVerificationVerifiedType = - (typeof UserVerificationVerifiedType)[keyof typeof UserVerificationVerifiedType] + (typeof UserVerificationVerifiedType)[keyof typeof UserVerificationVerifiedType]; export const UserVerificationVerifiedType = { - Business: 'Business', - Government: 'Government', -} as const + Business: "Business", + Government: "Government", +} as const; export interface UserVerification { - verified: boolean - verified_type?: UserVerificationVerifiedType + verified: boolean; + verified_type?: UserVerificationVerifiedType; } export type UserVerificationInfoReasonDescriptionEntitiesRefUrlType = - (typeof UserVerificationInfoReasonDescriptionEntitiesRefUrlType)[keyof typeof UserVerificationInfoReasonDescriptionEntitiesRefUrlType] + (typeof UserVerificationInfoReasonDescriptionEntitiesRefUrlType)[keyof typeof UserVerificationInfoReasonDescriptionEntitiesRefUrlType]; export const UserVerificationInfoReasonDescriptionEntitiesRefUrlType = { - ExternalUrl: 'ExternalUrl', -} as const + ExternalUrl: "ExternalUrl", +} as const; export interface UserVerificationInfoReasonDescriptionEntitiesRef { - url: string - url_type: UserVerificationInfoReasonDescriptionEntitiesRefUrlType + url: string; + url_type: UserVerificationInfoReasonDescriptionEntitiesRefUrlType; } export interface UserVerificationInfoReasonDescriptionEntities { - from_index: number - ref: UserVerificationInfoReasonDescriptionEntitiesRef - to_index: number + from_index: number; + ref: UserVerificationInfoReasonDescriptionEntitiesRef; + to_index: number; } export interface UserVerificationInfoReasonDescription { - entities: UserVerificationInfoReasonDescriptionEntities[] - text: string + entities: UserVerificationInfoReasonDescriptionEntities[]; + text: string; } export interface UserVerificationInfoReason { - description: UserVerificationInfoReasonDescription - override_verified_year?: number + description: UserVerificationInfoReasonDescription; + override_verified_year?: number; /** @pattern ^-?[0-9]+$ */ - verified_since_msec: string + verified_since_msec: string; } export interface UserVerificationInfo { - is_identity_verified: boolean - reason?: UserVerificationInfoReason + is_identity_verified: boolean; + reason?: UserVerificationInfoReason; } -export type UserAffiliatesHighlightedLabel = { [key: string]: unknown } +export type UserAffiliatesHighlightedLabel = { [key: string]: unknown }; -export type UserBusinessAccount = { [key: string]: unknown } +export type UserBusinessAccount = { [key: string]: unknown }; export interface User { - __typename: TypeName - affiliates_highlighted_label?: UserAffiliatesHighlightedLabel - avatar?: UserAvatar - business_account?: UserBusinessAccount - core?: UserCore - creator_subscriptions_count?: number - dm_permissions?: UserDmPermissions - has_graduated_access?: boolean - has_hidden_likes_on_profile?: boolean - has_hidden_subscriptions_on_profile?: boolean - has_nft_avatar?: boolean - highlights_info?: UserHighlightsInfo + __typename: TypeName; + affiliates_highlighted_label?: UserAffiliatesHighlightedLabel; + avatar?: UserAvatar; + business_account?: UserBusinessAccount; + core?: UserCore; + creator_subscriptions_count?: number; + dm_permissions?: UserDmPermissions; + has_graduated_access?: boolean; + has_hidden_likes_on_profile?: boolean; + has_hidden_subscriptions_on_profile?: boolean; + has_nft_avatar?: boolean; + highlights_info?: UserHighlightsInfo; /** @pattern ^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$ */ - id: string - is_blue_verified: boolean - is_profile_translatable?: boolean - legacy: UserLegacy - legacy_extended_profile?: UserLegacyExtendedProfile - location?: UserLocation - media_permissions?: UserMediaPermissions - parody_commentary_fan_label?: UserParodyCommentaryFanLabel - premium_gifting_eligible?: boolean - privacy?: UserPrivacy - professional?: UserProfessional - profile_bio?: ProfileBio - profile_description_language?: string - profile_image_shape: UserProfileImageShape - profile_sort_enabled?: boolean - relationship_perspectives?: UserRelationshipPerspectives + id: string; + is_blue_verified: boolean; + is_profile_translatable?: boolean; + legacy: UserLegacy; + legacy_extended_profile?: UserLegacyExtendedProfile; + location?: UserLocation; + media_permissions?: UserMediaPermissions; + parody_commentary_fan_label?: UserParodyCommentaryFanLabel; + premium_gifting_eligible?: boolean; + privacy?: UserPrivacy; + professional?: UserProfessional; + profile_bio?: ProfileBio; + profile_description_language?: string; + profile_image_shape: UserProfileImageShape; + profile_sort_enabled?: boolean; + relationship_perspectives?: UserRelationshipPerspectives; /** @pattern ^[0-9]+$ */ - rest_id: string - super_follow_eligible?: boolean - super_followed_by?: boolean - super_following?: boolean - super_follows_user_profile?: UserProfile - tipjar_settings?: UserTipJarSettings - user_seed_tweet_count?: number - verification?: UserVerification - verification_info?: UserVerificationInfo - verified_user_profiles?: UserProfile + rest_id: string; + super_follow_eligible?: boolean; + super_followed_by?: boolean; + super_following?: boolean; + super_follows_user_profile?: UserProfile; + tipjar_settings?: UserTipJarSettings; + user_seed_tweet_count?: number; + verification?: UserVerification; + verification_info?: UserVerificationInfo; + verified_user_profiles?: UserProfile; } export interface UserUnavailable { - __typename: TypeName - message?: string - reason: string + __typename: TypeName; + message?: string; + reason: string; } -export type UserUnion = User | UserUnavailable +export type UserUnion = User | UserUnavailable; export interface UserResults { - result?: UserUnion + result?: UserUnion; } export type CommunityInvitesPolicy = - (typeof CommunityInvitesPolicy)[keyof typeof CommunityInvitesPolicy] + (typeof CommunityInvitesPolicy)[keyof typeof CommunityInvitesPolicy]; export const CommunityInvitesPolicy = { - MemberInvitesAllowed: 'MemberInvitesAllowed', - ModeratorInvitesAllowed: 'ModeratorInvitesAllowed', -} as const + MemberInvitesAllowed: "MemberInvitesAllowed", + ModeratorInvitesAllowed: "ModeratorInvitesAllowed", +} as const; export type CommunityInvitesResultReason = - (typeof CommunityInvitesResultReason)[keyof typeof CommunityInvitesResultReason] + (typeof CommunityInvitesResultReason)[keyof typeof CommunityInvitesResultReason]; export const CommunityInvitesResultReason = { - Unavailable: 'Unavailable', -} as const + Unavailable: "Unavailable", +} as const; export interface CommunityInvitesResult { - __typename: TypeName - message: string - reason: CommunityInvitesResultReason + __typename: TypeName; + message: string; + reason: CommunityInvitesResultReason; } -export type CommunityJoinPolicy = - (typeof CommunityJoinPolicy)[keyof typeof CommunityJoinPolicy] +export type CommunityJoinPolicy = (typeof CommunityJoinPolicy)[keyof typeof CommunityJoinPolicy]; export const CommunityJoinPolicy = { - Open: 'Open', - RestrictedJoinRequestsRequireModeratorApproval: - 'RestrictedJoinRequestsRequireModeratorApproval', -} as const + Open: "Open", + RestrictedJoinRequestsRequireModeratorApproval: "RestrictedJoinRequestsRequireModeratorApproval", +} as const; export interface CommunityJoinRequestsResult { - __typename: TypeName + __typename: TypeName; } export interface PrimaryCommunityTopic { /** @pattern ^[0-9]+$ */ - topic_id: string - topic_name: string + topic_id: string; + topic_name: string; } -export type CommunityRole = (typeof CommunityRole)[keyof typeof CommunityRole] +export type CommunityRole = (typeof CommunityRole)[keyof typeof CommunityRole]; export const CommunityRole = { - NonMember: 'NonMember', -} as const + NonMember: "NonMember", +} as const; export interface CommunityRule { - description?: string - name: string + description?: string; + name: string; /** @pattern ^[0-9]+$ */ - rest_id: string + rest_id: string; } export interface CommunityUrlsPermalink { - url: string + url: string; } export interface CommunityUrls { - permalink: CommunityUrlsPermalink + permalink: CommunityUrlsPermalink; } -export type CommunityCustomBannerMedia = { [key: string]: unknown } +export type CommunityCustomBannerMedia = { [key: string]: unknown }; -export type CommunityDefaultBannerMedia = { [key: string]: unknown } +export type CommunityDefaultBannerMedia = { [key: string]: unknown }; -export type CommunityViewerRelationship = { [key: string]: unknown } +export type CommunityViewerRelationship = { [key: string]: unknown }; export interface Community { - __typename: TypeName - actions?: CommunityActions - admin_results?: UserResults - created_at?: number - creator_results?: UserResults - custom_banner_media?: CommunityCustomBannerMedia - default_banner_media?: CommunityDefaultBannerMedia - description?: string + __typename: TypeName; + actions?: CommunityActions; + admin_results?: UserResults; + created_at?: number; + creator_results?: UserResults; + custom_banner_media?: CommunityCustomBannerMedia; + default_banner_media?: CommunityDefaultBannerMedia; + description?: string; /** @pattern ^[0-9]+$ */ - id_str?: string - invites_policy?: CommunityInvitesPolicy - invites_result?: CommunityInvitesResult - is_pinned?: boolean - join_policy?: CommunityJoinPolicy - join_requests_result?: CommunityJoinRequestsResult - member_count?: number - members_facepile_results?: UserResults[] - moderator_count?: number - name?: string - primary_community_topic?: PrimaryCommunityTopic - question?: string - role?: CommunityRole - rules?: CommunityRule[] - search_tags?: string[] - show_only_users_to_display?: string[] - urls?: CommunityUrls - viewer_relationship?: CommunityViewerRelationship + id_str?: string; + invites_policy?: CommunityInvitesPolicy; + invites_result?: CommunityInvitesResult; + is_pinned?: boolean; + join_policy?: CommunityJoinPolicy; + join_requests_result?: CommunityJoinRequestsResult; + member_count?: number; + members_facepile_results?: UserResults[]; + moderator_count?: number; + name?: string; + primary_community_topic?: PrimaryCommunityTopic; + question?: string; + role?: CommunityRole; + rules?: CommunityRule[]; + search_tags?: string[]; + show_only_users_to_display?: string[]; + urls?: CommunityUrls; + viewer_relationship?: CommunityViewerRelationship; } export interface CommunityUnavailable { - __typename: TypeName + __typename: TypeName; } -export type CommunityUnion = Community | CommunityUnavailable +export type CommunityUnion = Community | CommunityUnavailable; export interface CommunityResult { - result: CommunityUnion + result: CommunityUnion; } export type AuthorCommunityRelationshipRole = - (typeof AuthorCommunityRelationshipRole)[keyof typeof AuthorCommunityRelationshipRole] + (typeof AuthorCommunityRelationshipRole)[keyof typeof AuthorCommunityRelationshipRole]; export const AuthorCommunityRelationshipRole = { - Member: 'Member', - Moderator: 'Moderator', - Admin: 'Admin', - NonMember: 'NonMember', -} as const + Member: "Member", + Moderator: "Moderator", + Admin: "Admin", + NonMember: "NonMember", +} as const; export interface AuthorCommunityRelationship { - community_results: CommunityResult - role?: AuthorCommunityRelationshipRole - user_results?: UserResults + community_results: CommunityResult; + role?: AuthorCommunityRelationshipRole; + user_results?: UserResults; } export interface BirdwatchPivotCallToAction { - destinationUrl: string - prompt: string - title: string + destinationUrl: string; + prompt: string; + title: string; } export type BirdwatchEntityRefType = - (typeof BirdwatchEntityRefType)[keyof typeof BirdwatchEntityRefType] + (typeof BirdwatchEntityRefType)[keyof typeof BirdwatchEntityRefType]; export const BirdwatchEntityRefType = { - TimelineUrl: 'TimelineUrl', - TimelineRichTextHashtag: 'TimelineRichTextHashtag', -} as const + TimelineUrl: "TimelineUrl", + TimelineRichTextHashtag: "TimelineRichTextHashtag", +} as const; export type BirdwatchEntityRefUrlType = - (typeof BirdwatchEntityRefUrlType)[keyof typeof BirdwatchEntityRefUrlType] + (typeof BirdwatchEntityRefUrlType)[keyof typeof BirdwatchEntityRefUrlType]; export const BirdwatchEntityRefUrlType = { - ExternalUrl: 'ExternalUrl', -} as const + ExternalUrl: "ExternalUrl", +} as const; export interface BirdwatchEntityRef { - text?: string - type: BirdwatchEntityRefType - url?: string - urlType?: BirdwatchEntityRefUrlType + text?: string; + type: BirdwatchEntityRefType; + url?: string; + urlType?: BirdwatchEntityRefUrlType; } export interface BirdwatchEntity { - fromIndex: number - ref: BirdwatchEntityRef - toIndex: number + fromIndex: number; + ref: BirdwatchEntityRef; + toIndex: number; } export interface BirdwatchPivotFooter { - entities: BirdwatchEntity[] - text: string + entities: BirdwatchEntity[]; + text: string; } export type BirdwatchPivotFooterIconType = - (typeof BirdwatchPivotFooterIconType)[keyof typeof BirdwatchPivotFooterIconType] + (typeof BirdwatchPivotFooterIconType)[keyof typeof BirdwatchPivotFooterIconType]; export const BirdwatchPivotFooterIconType = { - BirdwatchEyeOff: 'BirdwatchEyeOff', -} as const + BirdwatchEyeOff: "BirdwatchEyeOff", +} as const; export type BirdwatchPivotIconType = - (typeof BirdwatchPivotIconType)[keyof typeof BirdwatchPivotIconType] + (typeof BirdwatchPivotIconType)[keyof typeof BirdwatchPivotIconType]; export const BirdwatchPivotIconType = { - BirdwatchV1Icon: 'BirdwatchV1Icon', - BirdwatchCameraVideo: 'BirdwatchCameraVideo', -} as const + BirdwatchV1Icon: "BirdwatchV1Icon", + BirdwatchCameraVideo: "BirdwatchCameraVideo", +} as const; -export type GrokEntityRefType = - (typeof GrokEntityRefType)[keyof typeof GrokEntityRefType] +export type GrokEntityRefType = (typeof GrokEntityRefType)[keyof typeof GrokEntityRefType]; export const GrokEntityRefType = { - TimelineUrl: 'TimelineUrl', - TimelineRichTextHashtag: 'TimelineRichTextHashtag', -} as const + TimelineUrl: "TimelineUrl", + TimelineRichTextHashtag: "TimelineRichTextHashtag", +} as const; -export type GrokEntityRefUrlType = - (typeof GrokEntityRefUrlType)[keyof typeof GrokEntityRefUrlType] +export type GrokEntityRefUrlType = (typeof GrokEntityRefUrlType)[keyof typeof GrokEntityRefUrlType]; export const GrokEntityRefUrlType = { - ExternalUrl: 'ExternalUrl', -} as const + ExternalUrl: "ExternalUrl", +} as const; export interface GrokEntityRef { - display_url?: string - expanded_url?: string - text?: string - type: GrokEntityRefType - url?: string - url_type?: GrokEntityRefUrlType + display_url?: string; + expanded_url?: string; + text?: string; + type: GrokEntityRefType; + url?: string; + url_type?: GrokEntityRefUrlType; } export interface GrokEntity { - from_index: string - ref: GrokEntityRef - to_index: string + from_index: string; + ref: GrokEntityRef; + to_index: string; } export interface GrokTranslatedCommunityNote { - destination_language: string - rich_text_entities?: GrokEntity[] - source_language: string - translation: string - translation_available: boolean + destination_language: string; + rich_text_entities?: GrokEntity[]; + source_language: string; + translation: string; + translation_available: boolean; } export interface GrokTranslatedCommunityNoteWithAvailability { - data?: GrokTranslatedCommunityNote - is_available: boolean + data?: GrokTranslatedCommunityNote; + is_available: boolean; } export interface BirdwatchPivotNote { - grok_translated_community_note_with_availability?: GrokTranslatedCommunityNoteWithAvailability - is_community_note_translatable?: boolean - language?: string + grok_translated_community_note_with_availability?: GrokTranslatedCommunityNoteWithAvailability; + is_community_note_translatable?: boolean; + language?: string; /** @pattern ^[0-9]+$ */ - rest_id: string + rest_id: string; } export interface BirdwatchPivotSubtitle { - entities: BirdwatchEntity[] - text: string + entities: BirdwatchEntity[]; + text: string; } export type BirdwatchPivotVisualStyle = - (typeof BirdwatchPivotVisualStyle)[keyof typeof BirdwatchPivotVisualStyle] + (typeof BirdwatchPivotVisualStyle)[keyof typeof BirdwatchPivotVisualStyle]; export const BirdwatchPivotVisualStyle = { - Default: 'Default', - Tentative: 'Tentative', -} as const + Default: "Default", + Tentative: "Tentative", +} as const; export interface BirdwatchPivot { - callToAction?: BirdwatchPivotCallToAction - destinationUrl: string - footer?: BirdwatchPivotFooter - footerIconType?: BirdwatchPivotFooterIconType - iconType: BirdwatchPivotIconType - note?: BirdwatchPivotNote - shorttitle?: string - subtitle?: BirdwatchPivotSubtitle - title: string - titleDetail?: string - visualStyle?: BirdwatchPivotVisualStyle + callToAction?: BirdwatchPivotCallToAction; + destinationUrl: string; + footer?: BirdwatchPivotFooter; + footerIconType?: BirdwatchPivotFooterIconType; + iconType: BirdwatchPivotIconType; + note?: BirdwatchPivotNote; + shorttitle?: string; + subtitle?: BirdwatchPivotSubtitle; + title: string; + titleDetail?: string; + visualStyle?: BirdwatchPivotVisualStyle; } export interface TweetCardLegacyBindingValueDataImage { - alt?: string - height: number - url: string - width: number + alt?: string; + height: number; + url: string; + width: number; } export interface UserValue { /** @pattern ^[0-9]+$ */ - id_str: string + id_str: string; } -export type TweetCardLegacyBindingValueDataImageColorValue = { - [key: string]: unknown -} +export type TweetCardLegacyBindingValueDataImageColorValue = { [key: string]: unknown }; export interface TweetCardLegacyBindingValueData { - boolean_value?: boolean - image_color_value?: TweetCardLegacyBindingValueDataImageColorValue - image_value?: TweetCardLegacyBindingValueDataImage - scribe_key?: string - string_value?: string - type: string - user_value?: UserValue + boolean_value?: boolean; + image_color_value?: TweetCardLegacyBindingValueDataImageColorValue; + image_value?: TweetCardLegacyBindingValueDataImage; + scribe_key?: string; + string_value?: string; + type: string; + user_value?: UserValue; } export interface TweetCardLegacyBindingValue { - key: string - value: TweetCardLegacyBindingValueData + key: string; + value: TweetCardLegacyBindingValueData; } export type TweetCardPlatformAudienceName = - (typeof TweetCardPlatformAudienceName)[keyof typeof TweetCardPlatformAudienceName] + (typeof TweetCardPlatformAudienceName)[keyof typeof TweetCardPlatformAudienceName]; export const TweetCardPlatformAudienceName = { - production: 'production', -} as const + production: "production", +} as const; export interface TweetCardPlatformAudience { - name: TweetCardPlatformAudienceName + name: TweetCardPlatformAudienceName; } export interface TweetCardPlatformDevice { - name: string + name: string; /** @pattern ^[0-9]+$ */ - version: string + version: string; } export interface TweetCardPlatform { - audience: TweetCardPlatformAudience - device: TweetCardPlatformDevice + audience: TweetCardPlatformAudience; + device: TweetCardPlatformDevice; } export interface TweetCardPlatformData { - platform: TweetCardPlatform + platform: TweetCardPlatform; } export interface TweetCardLegacy { - binding_values: TweetCardLegacyBindingValue[] - card_platform?: TweetCardPlatformData - name: string - url: string - user_refs_results?: UserResults[] + binding_values: TweetCardLegacyBindingValue[]; + card_platform?: TweetCardPlatformData; + name: string; + url: string; + user_refs_results?: UserResults[]; } export interface TweetCard { - legacy?: TweetCardLegacy - rest_id?: string + legacy?: TweetCardLegacy; + rest_id?: string; } -export type CommunityRelationshipModerationState = { [key: string]: unknown } +export type CommunityRelationshipModerationState = { [key: string]: unknown }; export interface CommunityRelationship { - actions: CommunityActions - id: string - moderation_state: CommunityRelationshipModerationState + actions: CommunityActions; + id: string; + moderation_state: CommunityRelationshipModerationState; /** @pattern ^[0-9]+$ */ - rest_id: string + rest_id: string; } export interface ContentDisclosureAdvertisingDisclosure { - is_paid_promotion: boolean + is_paid_promotion: boolean; } export type ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource = - (typeof ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource)[keyof typeof ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource] + (typeof ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource)[keyof typeof ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource]; -export const ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource = - { - C2paClient: 'C2paClient', - ContentDisclosureAiGeneratedDisclosure: - 'ContentDisclosureAiGeneratedDisclosure', - UserDeclared: 'UserDeclared', - } as const +export const ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource = { + C2paClient: "C2paClient", + ContentDisclosureAiGeneratedDisclosure: "ContentDisclosureAiGeneratedDisclosure", + UserDeclared: "UserDeclared", +} as const; export interface ContentDisclosureAiGeneratedDisclosure { - ai_generated_detection_source?: ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource - can_edit: boolean - has_ai_generated_media: boolean + ai_generated_detection_source?: ContentDisclosureAiGeneratedDisclosureAiGeneratedDetectionSource; + can_edit: boolean; + has_ai_generated_media: boolean; } export interface ContentDisclosure { - advertising_disclosure?: ContentDisclosureAdvertisingDisclosure - ai_generated_disclosure?: ContentDisclosureAiGeneratedDisclosure + advertising_disclosure?: ContentDisclosureAdvertisingDisclosure; + ai_generated_disclosure?: ContentDisclosureAiGeneratedDisclosure; } export interface UserResultCore { - user_results: UserResults + user_results: UserResults; } export interface TweetEditControlInitial { /** @items.pattern ^[0-9]+$ */ - edit_tweet_ids: string[] + edit_tweet_ids: string[]; /** @pattern ^[0-9]+$ */ - editable_until_msecs: string + editable_until_msecs: string; /** @pattern ^[0-9]+$ */ - edits_remaining: string - is_edit_eligible: boolean + edits_remaining: string; + is_edit_eligible: boolean; } export interface TweetEditControl { - edit_control_initial?: TweetEditControlInitial + edit_control_initial?: TweetEditControlInitial; /** @items.pattern ^[0-9]+$ */ - edit_tweet_ids?: string[] + edit_tweet_ids?: string[]; /** @pattern ^[0-9]+$ */ - editable_until_msecs?: string + editable_until_msecs?: string; /** @pattern ^[0-9]+$ */ - edits_remaining?: string + edits_remaining?: string; /** @pattern ^[0-9]+$ */ - initial_tweet_id?: string - is_edit_eligible?: boolean + initial_tweet_id?: string; + is_edit_eligible?: boolean; } export interface TweetEditPrespective { - favorited?: boolean - retweeted?: boolean + favorited?: boolean; + retweeted?: boolean; } export interface GrokAnnotation { - is_image_editable_by_grok?: boolean + is_image_editable_by_grok?: boolean; } export interface AnalysisResults { - result: Tweet + result: Tweet; } -export type GrokShareAttachmentItemDeepsearchHeadersItem = { - [key: string]: unknown -} +export type GrokShareAttachmentItemDeepsearchHeadersItem = { [key: string]: unknown }; export interface GrokShareAttachmentItem { - analysis_post_id_results?: AnalysisResults - deepsearch_headers?: GrokShareAttachmentItemDeepsearchHeadersItem[] - media_urls: string[] - message: string + analysis_post_id_results?: AnalysisResults; + deepsearch_headers?: GrokShareAttachmentItemDeepsearchHeadersItem[]; + media_urls: string[]; + message: string; } export interface GrokShareAttachment { - items: GrokShareAttachmentItem[] + items: GrokShareAttachmentItem[]; } export interface Hashtag { - [key: string]: unknown + [key: string]: unknown; } export interface AdditionalMediaInfoCallToActionsUrl { - url: string + url: string; } export interface AdditionalMediaInfoCallToActions { - visit_site?: AdditionalMediaInfoCallToActionsUrl - watch_now?: AdditionalMediaInfoCallToActionsUrl + visit_site?: AdditionalMediaInfoCallToActionsUrl; + watch_now?: AdditionalMediaInfoCallToActionsUrl; } export interface AdditionalMediaInfo { - call_to_actions?: AdditionalMediaInfoCallToActions - description?: string - embeddable?: boolean - monetizable: boolean - source_user?: UserResultCore - title?: string + call_to_actions?: AdditionalMediaInfoCallToActions; + description?: string; + embeddable?: boolean; + monetizable: boolean; + source_user?: UserResultCore; + title?: string; } export interface AllowDownloadStatus { - allow_download?: boolean + allow_download?: boolean; } export type ExtMediaAvailabilityStatus = - (typeof ExtMediaAvailabilityStatus)[keyof typeof ExtMediaAvailabilityStatus] + (typeof ExtMediaAvailabilityStatus)[keyof typeof ExtMediaAvailabilityStatus]; export const ExtMediaAvailabilityStatus = { - Available: 'Available', - Unavailable: 'Unavailable', -} as const + Available: "Available", + Unavailable: "Unavailable", +} as const; export interface ExtMediaAvailability { - reason?: string - status?: ExtMediaAvailabilityStatus + reason?: string; + status?: ExtMediaAvailabilityStatus; } export interface GrokImageAnnotation { - prompt: string - upsampled_prompt: string + prompt: string; + upsampled_prompt: string; } export interface MediaResult { - grok_image_annotation?: GrokImageAnnotation - media_key: string + grok_image_annotation?: GrokImageAnnotation; + media_key: string; } export interface MediaResults { - result: MediaResult + result: MediaResult; } export interface MediaOriginalInfoFocusRect { - h: number - w: number - x: number - y: number + h: number; + w: number; + x: number; + y: number; } export interface MediaOriginalInfo { - focus_rects?: MediaOriginalInfoFocusRect[] - height: number - width: number + focus_rects?: MediaOriginalInfoFocusRect[]; + height: number; + width: number; } export interface SensitiveMediaWarning { - adult_content?: boolean - graphic_violence?: boolean - other?: boolean + adult_content?: boolean; + graphic_violence?: boolean; + other?: boolean; } -export type MediaSizeResize = - (typeof MediaSizeResize)[keyof typeof MediaSizeResize] +export type MediaSizeResize = (typeof MediaSizeResize)[keyof typeof MediaSizeResize]; export const MediaSizeResize = { - crop: 'crop', - fit: 'fit', -} as const + crop: "crop", + fit: "fit", +} as const; export interface MediaSize { - h: number - resize: MediaSizeResize - w: number + h: number; + resize: MediaSizeResize; + w: number; } export interface MediaSizes { - large: MediaSize - medium: MediaSize - small: MediaSize - thumb: MediaSize + large: MediaSize; + medium: MediaSize; + small: MediaSize; + thumb: MediaSize; } -export type MediaType = (typeof MediaType)[keyof typeof MediaType] +export type MediaType = (typeof MediaType)[keyof typeof MediaType]; export const MediaType = { - photo: 'photo', - video: 'video', - animated_gif: 'animated_gif', -} as const + photo: "photo", + video: "video", + animated_gif: "animated_gif", +} as const; export interface MediaVideoInfoVariant { - bitrate?: number - content_type: string - url: string + bitrate?: number; + content_type: string; + url: string; } export interface MediaVideoInfo { - aspect_ratio: number[] - duration_millis?: number - variants: MediaVideoInfoVariant[] + aspect_ratio: number[]; + duration_millis?: number; + variants: MediaVideoInfoVariant[]; } -export type MediaFeatures = { [key: string]: unknown } +export type MediaFeatures = { [key: string]: unknown }; export interface Media { - additional_media_info?: AdditionalMediaInfo - allow_download_status?: AllowDownloadStatus - display_url: string - expanded_url: string - ext_alt_text?: string - ext_media_availability: ExtMediaAvailability - features?: MediaFeatures - grok_post_id?: string + additional_media_info?: AdditionalMediaInfo; + allow_download_status?: AllowDownloadStatus; + display_url: string; + expanded_url: string; + ext_alt_text?: string; + ext_media_availability: ExtMediaAvailability; + features?: MediaFeatures; + grok_post_id?: string; /** @pattern ^[0-9]+$ */ - id_str: string - indices: number[] - media_key: string - media_results?: MediaResults - media_url_https: string - original_info: MediaOriginalInfo - sensitive_media_warning?: SensitiveMediaWarning - sizes: MediaSizes + id_str: string; + indices: number[]; + media_key: string; + media_results?: MediaResults; + media_url_https: string; + original_info: MediaOriginalInfo; + sensitive_media_warning?: SensitiveMediaWarning; + sizes: MediaSizes; /** @pattern ^[0-9]+$ */ - source_status_id_str?: string + source_status_id_str?: string; /** @pattern ^[0-9]+$ */ - source_user_id_str?: string - type: MediaType - url: string - video_info?: MediaVideoInfo + source_user_id_str?: string; + type: MediaType; + url: string; + video_info?: MediaVideoInfo; } -export type SmarttagTagInfo = { [key: string]: unknown } +export type SmarttagTagInfo = { [key: string]: unknown }; export interface SmarttagTag { - info: SmarttagTagInfo + info: SmarttagTagInfo; } export interface Smarttag { - indices: number[] - tag: SmarttagTag - text: string + indices: number[]; + tag: SmarttagTag; + text: string; } export interface Symbol { - [key: string]: unknown + [key: string]: unknown; } export interface Timestamp { - indices: number[] - seconds: number - text: string + indices: number[]; + seconds: number; + text: string; } export interface Url { - display_url: string - expanded_url?: string - indices: number[] - url: string + display_url: string; + expanded_url?: string; + indices: number[]; + url: string; } export interface UserMention { - [key: string]: unknown + [key: string]: unknown; } export interface Entities { - hashtags?: Hashtag[] - media?: Media[] - smarttags?: Smarttag[] - symbols?: Symbol[] - timestamps?: Timestamp[] - urls?: Url[] - user_mentions?: UserMention[] + hashtags?: Hashtag[]; + media?: Media[]; + smarttags?: Smarttag[]; + symbols?: Symbol[]; + timestamps?: Timestamp[]; + urls?: Url[]; + user_mentions?: UserMention[]; } -export type GrokTranslatedPostAssociatedData = { [key: string]: unknown } +export type GrokTranslatedPostAssociatedData = { [key: string]: unknown }; export interface GrokTranslatedPost { - associated_data?: GrokTranslatedPostAssociatedData - destination_language?: string - entities?: Entities - preview_translation?: string - source_language?: string - translation?: string + associated_data?: GrokTranslatedPostAssociatedData; + destination_language?: string; + entities?: Entities; + preview_translation?: string; + source_language?: string; + translation?: string; } export interface GrokTranslatedPostWithAvailability { - data?: GrokTranslatedPost - is_available: boolean + data?: GrokTranslatedPost; + is_available: boolean; } export interface MediaStats { - viewCount: number + viewCount: number; } -export type MediaExtendedType = - (typeof MediaExtendedType)[keyof typeof MediaExtendedType] +export type MediaExtendedType = (typeof MediaExtendedType)[keyof typeof MediaExtendedType]; export const MediaExtendedType = { - photo: 'photo', - video: 'video', - animated_gif: 'animated_gif', -} as const + photo: "photo", + video: "video", + animated_gif: "animated_gif", +} as const; -export type MediaExtendedFeatures = { [key: string]: unknown } +export type MediaExtendedFeatures = { [key: string]: unknown }; export interface MediaExtended { - additional_media_info?: AdditionalMediaInfo - allow_download_status?: AllowDownloadStatus - display_url: string - expanded_url: string - ext_alt_text?: string - ext_media_availability?: ExtMediaAvailability - features?: MediaExtendedFeatures - grok_post_id?: string + additional_media_info?: AdditionalMediaInfo; + allow_download_status?: AllowDownloadStatus; + display_url: string; + expanded_url: string; + ext_alt_text?: string; + ext_media_availability?: ExtMediaAvailability; + features?: MediaExtendedFeatures; + grok_post_id?: string; /** @pattern ^[0-9]+$ */ - id_str: string - indices: number[] - mediaStats?: MediaStats - media_key: string - media_results?: MediaResults - media_url_https: string - original_info: MediaOriginalInfo - sensitive_media_warning?: SensitiveMediaWarning - sizes: MediaSizes + id_str: string; + indices: number[]; + mediaStats?: MediaStats; + media_key: string; + media_results?: MediaResults; + media_url_https: string; + original_info: MediaOriginalInfo; + sensitive_media_warning?: SensitiveMediaWarning; + sizes: MediaSizes; /** @pattern ^[0-9]+$ */ - source_status_id_str?: string + source_status_id_str?: string; /** @pattern ^[0-9]+$ */ - source_user_id_str?: string - type: MediaExtendedType - url: string - video_info?: MediaVideoInfo + source_user_id_str?: string; + type: MediaExtendedType; + url: string; + video_info?: MediaVideoInfo; } export interface ExtendedEntities { - media: MediaExtended[] + media: MediaExtended[]; } export type TweetLegacyLimitedActions = - (typeof TweetLegacyLimitedActions)[keyof typeof TweetLegacyLimitedActions] + (typeof TweetLegacyLimitedActions)[keyof typeof TweetLegacyLimitedActions]; export const TweetLegacyLimitedActions = { - limited_replies: 'limited_replies', - non_compliant: 'non_compliant', - dynamic_product_ad: 'dynamic_product_ad', - stale_tweet: 'stale_tweet', - community_tweet_non_member_public_community: - 'community_tweet_non_member_public_community', - community_tweet_non_member_closed_community: - 'community_tweet_non_member_closed_community', - blocked_viewer: 'blocked_viewer', -} as const + limited_replies: "limited_replies", + non_compliant: "non_compliant", + dynamic_product_ad: "dynamic_product_ad", + stale_tweet: "stale_tweet", + community_tweet_non_member_public_community: "community_tweet_non_member_public_community", + community_tweet_non_member_closed_community: "community_tweet_non_member_closed_community", + blocked_viewer: "blocked_viewer", +} as const; export interface QuotedStatusPermalink { - display: string - expanded: string - url: string + display: string; + expanded: string; + url: string; } export interface TweetLegacyScopes { - followers: boolean + followers: boolean; } export interface SelfThread { /** @pattern ^[0-9]+$ */ - id_str: string + id_str: string; } -export type TweetLegacyConversationControl = { [key: string]: unknown } +export type TweetLegacyConversationControl = { [key: string]: unknown }; -export type TweetLegacyPlace = { [key: string]: unknown } +export type TweetLegacyPlace = { [key: string]: unknown }; export interface TweetLegacy { - bookmark_count: number - bookmarked: boolean - conversation_control?: TweetLegacyConversationControl + bookmark_count: number; + bookmarked: boolean; + conversation_control?: TweetLegacyConversationControl; /** @pattern ^[0-9]+$ */ - conversation_id_str: string - created_at: TwitterTimeFormat - display_text_range: number[] - entities?: Entities - extended_entities?: ExtendedEntities - favorite_count: number - favorited: boolean - full_text: string + conversation_id_str: string; + created_at: TwitterTimeFormat; + display_text_range: number[]; + entities?: Entities; + extended_entities?: ExtendedEntities; + favorite_count: number; + favorited: boolean; + full_text: string; /** @pattern ^[0-9]+$ */ - id_str: string - in_reply_to_screen_name?: string + id_str: string; + in_reply_to_screen_name?: string; /** @pattern ^[0-9]+$ */ - in_reply_to_status_id_str?: string + in_reply_to_status_id_str?: string; /** @pattern ^[0-9]+$ */ - in_reply_to_user_id_str?: string - is_quote_status: boolean - lang: string - limited_actions?: TweetLegacyLimitedActions - place?: TweetLegacyPlace - possibly_sensitive?: boolean - possibly_sensitive_editable?: boolean - quote_count: number + in_reply_to_user_id_str?: string; + is_quote_status: boolean; + lang: string; + limited_actions?: TweetLegacyLimitedActions; + place?: TweetLegacyPlace; + possibly_sensitive?: boolean; + possibly_sensitive_editable?: boolean; + quote_count: number; /** @pattern ^[0-9]+$ */ - quoted_status_id_str?: string - quoted_status_permalink?: QuotedStatusPermalink - reply_count: number - retweet_count: number - retweeted: boolean - retweeted_status_result?: ItemResult - scopes?: TweetLegacyScopes - self_thread?: SelfThread + quoted_status_id_str?: string; + quoted_status_permalink?: QuotedStatusPermalink; + reply_count: number; + retweet_count: number; + retweeted: boolean; + retweeted_status_result?: ItemResult; + scopes?: TweetLegacyScopes; + self_thread?: SelfThread; /** @pattern ^[0-9]+$ */ - user_id_str: string + user_id_str: string; } export interface NoteTweetResultMediaInlineMedia { - index: number + index: number; /** @pattern ^[0-9]+$ */ - media_id: string + media_id: string; } export interface NoteTweetResultMedia { - inline_media: NoteTweetResultMediaInlineMedia[] + inline_media: NoteTweetResultMediaInlineMedia[]; } export type NoteTweetResultRichTextTagRichtextTypesItem = - (typeof NoteTweetResultRichTextTagRichtextTypesItem)[keyof typeof NoteTweetResultRichTextTagRichtextTypesItem] + (typeof NoteTweetResultRichTextTagRichtextTypesItem)[keyof typeof NoteTweetResultRichTextTagRichtextTypesItem]; export const NoteTweetResultRichTextTagRichtextTypesItem = { - Bold: 'Bold', - Italic: 'Italic', -} as const + Bold: "Bold", + Italic: "Italic", +} as const; export interface NoteTweetResultRichTextTag { - from_index: number - richtext_types: NoteTweetResultRichTextTagRichtextTypesItem[] - to_index: number + from_index: number; + richtext_types: NoteTweetResultRichTextTagRichtextTypesItem[]; + to_index: number; } export interface NoteTweetResultRichText { - richtext_tags: NoteTweetResultRichTextTag[] + richtext_tags: NoteTweetResultRichTextTag[]; } export interface NoteTweetResultData { - entity_set: Entities + entity_set: Entities; /** @pattern ^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$ */ - id: string - media?: NoteTweetResultMedia - richtext?: NoteTweetResultRichText - text: string + id: string; + media?: NoteTweetResultMedia; + richtext?: NoteTweetResultRichText; + text: string; } export interface NoteTweetResult { - result: NoteTweetResultData + result: NoteTweetResultData; } export interface NoteTweet { - is_expandable?: boolean - note_tweet_results: NoteTweetResult + is_expandable?: boolean; + note_tweet_results: NoteTweetResult; } export type TweetPostCtaDisplayLocationsItem = - (typeof TweetPostCtaDisplayLocationsItem)[keyof typeof TweetPostCtaDisplayLocationsItem] + (typeof TweetPostCtaDisplayLocationsItem)[keyof typeof TweetPostCtaDisplayLocationsItem]; export const TweetPostCtaDisplayLocationsItem = { - TweetDetails: 'TweetDetails', - ForYouTimeline: 'ForYouTimeline', - RepliesTimeline: 'RepliesTimeline', -} as const + TweetDetails: "TweetDetails", + ForYouTimeline: "ForYouTimeline", + RepliesTimeline: "RepliesTimeline", +} as const; export interface PostCtaUserResultsAvatar { - image_url: string + image_url: string; } export interface PostCtaUserResultsResult { - __typename: TypeName - avatar: PostCtaUserResultsAvatar + __typename: TypeName; + avatar: PostCtaUserResultsAvatar; } export interface PostCtaUserResults { - result: PostCtaUserResultsResult + result: PostCtaUserResultsResult; } export interface PostCtaTitleTextEntityRef { - __typename: TypeName - user_results?: PostCtaUserResults + __typename: TypeName; + user_results?: PostCtaUserResults; } export interface PostCtaTitleTextEntity { - fromIndex: number - ref: PostCtaTitleTextEntityRef - toIndex: number + fromIndex: number; + ref: PostCtaTitleTextEntityRef; + toIndex: number; } export interface PostCtaTitleText { - entities: PostCtaTitleTextEntity[] - text: string + entities: PostCtaTitleTextEntity[]; + text: string; } export interface PostCta { - action_url: string - background_color: string - show_chevron: boolean - stroke_color: string - title_text: PostCtaTitleText + action_url: string; + background_color: string; + show_chevron: boolean; + stroke_color: string; + title_text: PostCtaTitleText; } export interface TweetPostCta { - display_locations: TweetPostCtaDisplayLocationsItem[] - post_cta: PostCta - scribe_id?: string + display_locations: TweetPostCtaDisplayLocationsItem[]; + post_cta: PostCta; + scribe_id?: string; } export interface TweetPreviousCounts { - bookmark_count: number - favorite_count: number - quote_count: number - reply_count: number - retweet_count: number + bookmark_count: number; + favorite_count: number; + quote_count: number; + reply_count: number; + retweet_count: number; } export interface QuotedRefResult { - result?: TweetUnion + result?: TweetUnion; } export interface SuperFollowsReplyUserResultCore { - screen_name: string + screen_name: string; } export interface SuperFollowsReplyUserResultData { - __typename: TypeName - core?: SuperFollowsReplyUserResultCore + __typename: TypeName; + core?: SuperFollowsReplyUserResultCore; } export interface SuperFollowsReplyUserResult { - result: SuperFollowsReplyUserResultData + result: SuperFollowsReplyUserResultData; } export interface TrendResults { /** @pattern ^[0-9]+$ */ - rest_id: string + rest_id: string; } export type UnifiedCardCardFetchState = - (typeof UnifiedCardCardFetchState)[keyof typeof UnifiedCardCardFetchState] + (typeof UnifiedCardCardFetchState)[keyof typeof UnifiedCardCardFetchState]; export const UnifiedCardCardFetchState = { - NoCard: 'NoCard', -} as const + NoCard: "NoCard", +} as const; export interface UnifiedCard { - card_fetch_state: UnifiedCardCardFetchState + card_fetch_state: UnifiedCardCardFetchState; } -export type TweetViewState = - (typeof TweetViewState)[keyof typeof TweetViewState] +export type TweetViewState = (typeof TweetViewState)[keyof typeof TweetViewState]; export const TweetViewState = { - Enabled: 'Enabled', - EnabledWithCount: 'EnabledWithCount', -} as const + Enabled: "Enabled", + EnabledWithCount: "EnabledWithCount", +} as const; export interface TweetView { /** @pattern ^[0-9]+$ */ - count?: string - state: TweetViewState + count?: string; + state: TweetViewState; } -export type TweetQuickPromoteEligibility = { [key: string]: unknown } +export type TweetQuickPromoteEligibility = { [key: string]: unknown }; -export type TweetUnmentionData = { [key: string]: unknown } +export type TweetUnmentionData = { [key: string]: unknown }; export interface Tweet { - __typename?: TypeName - article?: Article - author_community_relationship?: AuthorCommunityRelationship - birdwatch_pivot?: BirdwatchPivot - card?: TweetCard - community_relationship?: CommunityRelationship - community_results?: CommunityResult - content_disclosure?: ContentDisclosure - core?: UserResultCore - edit_control?: TweetEditControl - edit_prespective?: TweetEditPrespective - grok_analysis_button?: boolean - grok_analysis_followups?: string[] - grok_annotations?: GrokAnnotation - grok_share_attachment?: GrokShareAttachment - grok_translated_post_with_availability?: GrokTranslatedPostWithAvailability - has_birdwatch_notes?: boolean - is_translatable?: boolean - legacy?: TweetLegacy - note_tweet?: NoteTweet - postCta?: TweetPostCta - post_image_description?: string - post_video_description?: string - previous_counts?: TweetPreviousCounts - quick_promote_eligibility?: TweetQuickPromoteEligibility - quotedRefResult?: QuotedRefResult - quoted_status_result?: ItemResult + __typename?: TypeName; + article?: Article; + author_community_relationship?: AuthorCommunityRelationship; + birdwatch_pivot?: BirdwatchPivot; + card?: TweetCard; + community_relationship?: CommunityRelationship; + community_results?: CommunityResult; + content_disclosure?: ContentDisclosure; + core?: UserResultCore; + edit_control?: TweetEditControl; + edit_prespective?: TweetEditPrespective; + grok_analysis_button?: boolean; + grok_analysis_followups?: string[]; + grok_annotations?: GrokAnnotation; + grok_share_attachment?: GrokShareAttachment; + grok_translated_post_with_availability?: GrokTranslatedPostWithAvailability; + has_birdwatch_notes?: boolean; + is_translatable?: boolean; + legacy?: TweetLegacy; + note_tweet?: NoteTweet; + postCta?: TweetPostCta; + post_image_description?: string; + post_video_description?: string; + previous_counts?: TweetPreviousCounts; + quick_promote_eligibility?: TweetQuickPromoteEligibility; + quotedRefResult?: QuotedRefResult; + quoted_status_result?: ItemResult; /** @pattern ^[0-9]+$ */ - rest_id: string - source?: string - superFollowsReplyUserResult?: SuperFollowsReplyUserResult - trend_results?: TrendResults - unified_card?: UnifiedCard - unmention_data?: TweetUnmentionData - views?: TweetView + rest_id: string; + source?: string; + superFollowsReplyUserResult?: SuperFollowsReplyUserResult; + trend_results?: TrendResults; + unified_card?: UnifiedCard; + unmention_data?: TweetUnmentionData; + views?: TweetView; } export type TweetInterstitialTextEntityRefType = - (typeof TweetInterstitialTextEntityRefType)[keyof typeof TweetInterstitialTextEntityRefType] + (typeof TweetInterstitialTextEntityRefType)[keyof typeof TweetInterstitialTextEntityRefType]; export const TweetInterstitialTextEntityRefType = { - TimelineUrl: 'TimelineUrl', -} as const + TimelineUrl: "TimelineUrl", +} as const; export type TweetInterstitialTextEntityRefUrlType = - (typeof TweetInterstitialTextEntityRefUrlType)[keyof typeof TweetInterstitialTextEntityRefUrlType] + (typeof TweetInterstitialTextEntityRefUrlType)[keyof typeof TweetInterstitialTextEntityRefUrlType]; export const TweetInterstitialTextEntityRefUrlType = { - ExternalUrl: 'ExternalUrl', -} as const + ExternalUrl: "ExternalUrl", +} as const; export interface TweetInterstitialTextEntityRef { - type: TweetInterstitialTextEntityRefType - url: string - urlType: TweetInterstitialTextEntityRefUrlType + type: TweetInterstitialTextEntityRefType; + url: string; + urlType: TweetInterstitialTextEntityRefUrlType; } export interface TweetInterstitialTextEntity { - fromIndex: number - ref: TweetInterstitialTextEntityRef - toIndex: number + fromIndex: number; + ref: TweetInterstitialTextEntityRef; + toIndex: number; } export interface TweetInterstitialText { - entities: TweetInterstitialTextEntity[] - rtl: boolean - text: string + entities: TweetInterstitialTextEntity[]; + rtl: boolean; + text: string; } export interface MediaVisibilityResultsBlurredImageInterstitial { - opacity: number - text: TweetInterstitialText - title: TweetInterstitialText + opacity: number; + text: TweetInterstitialText; + title: TweetInterstitialText; } export interface MediaVisibilityResults { - blurred_image_interstitial: MediaVisibilityResultsBlurredImageInterstitial + blurred_image_interstitial: MediaVisibilityResultsBlurredImageInterstitial; } export type TweetInterstitialDisplayType = - (typeof TweetInterstitialDisplayType)[keyof typeof TweetInterstitialDisplayType] + (typeof TweetInterstitialDisplayType)[keyof typeof TweetInterstitialDisplayType]; export const TweetInterstitialDisplayType = { - NonCompliant: 'NonCompliant', - EntireTweet: 'EntireTweet', -} as const + NonCompliant: "NonCompliant", + EntireTweet: "EntireTweet", +} as const; export interface TweetInterstitialRevealText { - entities: TweetInterstitialTextEntity[] - rtl: boolean - text: string + entities: TweetInterstitialTextEntity[]; + rtl: boolean; + text: string; } export interface TweetInterstitial { - __typename: TypeName - displayType: TweetInterstitialDisplayType - revealText: TweetInterstitialRevealText - text: TweetInterstitialText + __typename: TypeName; + displayType: TweetInterstitialDisplayType; + revealText: TweetInterstitialRevealText; + text: TweetInterstitialText; } -export type TweetWithVisibilityResultsLimitedActionResults = { - [key: string]: unknown -} +export type TweetWithVisibilityResultsLimitedActionResults = { [key: string]: unknown }; export interface TweetWithVisibilityResults { - __typename: TypeName - limitedActionResults?: TweetWithVisibilityResultsLimitedActionResults - mediaVisibilityResults?: MediaVisibilityResults - tweet: Tweet - tweetInterstitial?: TweetInterstitial + __typename: TypeName; + limitedActionResults?: TweetWithVisibilityResultsLimitedActionResults; + mediaVisibilityResults?: MediaVisibilityResults; + tweet: Tweet; + tweetInterstitial?: TweetInterstitial; } export interface TweetTombstone { - __typename?: TypeName - [key: string]: unknown + __typename?: TypeName; + [key: string]: unknown; } export interface TweetUnavailable { - __typename?: TypeName - reason?: string + __typename?: TypeName; + reason?: string; } export type TweetPreviewDisplayCtaUrlUrlType = - (typeof TweetPreviewDisplayCtaUrlUrlType)[keyof typeof TweetPreviewDisplayCtaUrlUrlType] + (typeof TweetPreviewDisplayCtaUrlUrlType)[keyof typeof TweetPreviewDisplayCtaUrlUrlType]; export const TweetPreviewDisplayCtaUrlUrlType = { - DeepLink: 'DeepLink', - UrtEndpoint: 'UrtEndpoint', - ExternalUrl: 'ExternalUrl', -} as const + DeepLink: "DeepLink", + UrtEndpoint: "UrtEndpoint", + ExternalUrl: "ExternalUrl", +} as const; export interface TweetPreviewDisplayCtaUrl { - url: string - urlType: TweetPreviewDisplayCtaUrlUrlType + url: string; + urlType: TweetPreviewDisplayCtaUrlUrlType; } export interface TweetPreviewDisplayCta { - title: string - url: TweetPreviewDisplayCtaUrl + title: string; + url: TweetPreviewDisplayCtaUrl; } export type LimitedActionResultsDataAction = - (typeof LimitedActionResultsDataAction)[keyof typeof LimitedActionResultsDataAction] + (typeof LimitedActionResultsDataAction)[keyof typeof LimitedActionResultsDataAction]; export const LimitedActionResultsDataAction = { - Reply: 'Reply', - Retweet: 'Retweet', - QuoteTweet: 'QuoteTweet', - Like: 'Like', - React: 'React', - AddToBookmarks: 'AddToBookmarks', - AddToMoment: 'AddToMoment', - PinToProfile: 'PinToProfile', - ViewHiddenReplies: 'ViewHiddenReplies', - VoteOnPoll: 'VoteOnPoll', - ShowRetweetActionMenu: 'ShowRetweetActionMenu', - ReplyDownVote: 'ReplyDownVote', - SendViaDm: 'SendViaDm', - ViewPostEngagements: 'ViewPostEngagements', -} as const + Reply: "Reply", + Retweet: "Retweet", + QuoteTweet: "QuoteTweet", + Like: "Like", + React: "React", + AddToBookmarks: "AddToBookmarks", + AddToMoment: "AddToMoment", + PinToProfile: "PinToProfile", + ViewHiddenReplies: "ViewHiddenReplies", + VoteOnPoll: "VoteOnPoll", + ShowRetweetActionMenu: "ShowRetweetActionMenu", + ReplyDownVote: "ReplyDownVote", + SendViaDm: "SendViaDm", + ViewPostEngagements: "ViewPostEngagements", +} as const; export interface LimitedActionResultsData { - action: LimitedActionResultsDataAction + action: LimitedActionResultsDataAction; } export interface TweetLimitedActionResults { - limited_actions: LimitedActionResultsData[] + limited_actions: LimitedActionResultsData[]; } export interface ArticlePreview { - article_id: number - cover_media?: ArticleCoverMedia - preview_text: string - title: string + article_id: number; + cover_media?: ArticleCoverMedia; + preview_text: string; + title: string; } export interface TweetPreviewDisplayTweetViewCount { /** @pattern ^[0-9]+$ */ - count: string + count: string; } -export type TweetPreviewDisplayTweetEntities = { [key: string]: unknown } +export type TweetPreviewDisplayTweetEntities = { [key: string]: unknown }; export interface TweetPreviewDisplayTweet { - article_preview?: ArticlePreview - bookmark_count: number - core: UserResultCore - created_at: TwitterTimeFormat - entities: TweetPreviewDisplayTweetEntities - favorite_count: number - quote_count: number - reply_count: number + article_preview?: ArticlePreview; + bookmark_count: number; + core: UserResultCore; + created_at: TwitterTimeFormat; + entities: TweetPreviewDisplayTweetEntities; + favorite_count: number; + quote_count: number; + reply_count: number; /** @pattern ^[0-9]+$ */ - rest_id: string - retweet_count: number - text: string - view_count: TweetPreviewDisplayTweetViewCount + rest_id: string; + retweet_count: number; + text: string; + view_count: TweetPreviewDisplayTweetViewCount; } export interface TweetPreviewDisplay { - __typename: TypeName - cta: TweetPreviewDisplayCta - limited_action_results: TweetLimitedActionResults - tweet: TweetPreviewDisplayTweet + __typename: TypeName; + cta: TweetPreviewDisplayCta; + limited_action_results: TweetLimitedActionResults; + tweet: TweetPreviewDisplayTweet; } export type TweetUnion = @@ -1731,186 +1709,184 @@ export type TweetUnion = | TweetWithVisibilityResults | TweetTombstone | TweetUnavailable - | TweetPreviewDisplay + | TweetPreviewDisplay; export interface ItemResult { - __typename?: TypeName - result?: TweetUnion + __typename?: TypeName; + result?: TweetUnion; } -export type TimelineTweetPromotedMetadata = { [key: string]: unknown } +export type TimelineTweetPromotedMetadata = { [key: string]: unknown }; export interface TimelineTweet { - __typename: TypeName - hasModeratedReplies?: boolean - highlights?: Highlight - itemType: ContentItemType - promotedMetadata?: TimelineTweetPromotedMetadata - socialContext?: SocialContextUnion - tweetDisplayType: TimelineTweetTweetDisplayType - tweet_results: ItemResult + __typename: TypeName; + hasModeratedReplies?: boolean; + highlights?: Highlight; + itemType: ContentItemType; + promotedMetadata?: TimelineTweetPromotedMetadata; + socialContext?: SocialContextUnion; + tweetDisplayType: TimelineTweetTweetDisplayType; + tweet_results: ItemResult; } -export type CursorType = (typeof CursorType)[keyof typeof CursorType] +export type CursorType = (typeof CursorType)[keyof typeof CursorType]; export const CursorType = { - Top: 'Top', - Bottom: 'Bottom', - ShowMore: 'ShowMore', - ShowMoreThreads: 'ShowMoreThreads', - Gap: 'Gap', - ShowMoreThreadsPrompt: 'ShowMoreThreadsPrompt', -} as const + Top: "Top", + Bottom: "Bottom", + ShowMore: "ShowMore", + ShowMoreThreads: "ShowMoreThreads", + Gap: "Gap", + ShowMoreThreadsPrompt: "ShowMoreThreadsPrompt", +} as const; export interface DisplayTreatment { - actionText: string - labelText?: string + actionText: string; + labelText?: string; } export interface TimelineTimelineCursor { - __typename: TypeName - cursorType: CursorType - displayTreatment?: DisplayTreatment - entryType?: ContentEntryType - itemType?: ContentEntryType - stopOnEmptyResponse?: boolean - value: string + __typename: TypeName; + cursorType: CursorType; + displayTreatment?: DisplayTreatment; + entryType?: ContentEntryType; + itemType?: ContentEntryType; + stopOnEmptyResponse?: boolean; + value: string; } export type TimelineUserUserDisplayType = - (typeof TimelineUserUserDisplayType)[keyof typeof TimelineUserUserDisplayType] + (typeof TimelineUserUserDisplayType)[keyof typeof TimelineUserUserDisplayType]; export const TimelineUserUserDisplayType = { - User: 'User', - UserDetailed: 'UserDetailed', - SubscribableUser: 'SubscribableUser', - UserConcise: 'UserConcise', -} as const + User: "User", + UserDetailed: "UserDetailed", + SubscribableUser: "SubscribableUser", + UserConcise: "UserConcise", +} as const; export interface TimelineUser { - __typename: TypeName - itemType: ContentItemType - socialContext?: SocialContextUnion - userDisplayType: TimelineUserUserDisplayType - user_results: UserResults + __typename: TypeName; + itemType: ContentItemType; + socialContext?: SocialContextUnion; + userDisplayType: TimelineUserUserDisplayType; + user_results: UserResults; } export interface TimelinePrompt { - __typename?: TypeName - [key: string]: unknown + __typename?: TypeName; + [key: string]: unknown; } export interface TimelineMessagePrompt { - __typename?: TypeName - [key: string]: unknown + __typename?: TypeName; + [key: string]: unknown; } export interface TimelineCommunity { - __typename?: TypeName - [key: string]: unknown + __typename?: TypeName; + [key: string]: unknown; } export type TimelineTombstoneTombstoneDisplayType = - (typeof TimelineTombstoneTombstoneDisplayType)[keyof typeof TimelineTombstoneTombstoneDisplayType] + (typeof TimelineTombstoneTombstoneDisplayType)[keyof typeof TimelineTombstoneTombstoneDisplayType]; export const TimelineTombstoneTombstoneDisplayType = { - Inline: 'Inline', -} as const + Inline: "Inline", +} as const; -export type TombstoneRefType = - (typeof TombstoneRefType)[keyof typeof TombstoneRefType] +export type TombstoneRefType = (typeof TombstoneRefType)[keyof typeof TombstoneRefType]; export const TombstoneRefType = { - TimelineUrl: 'TimelineUrl', -} as const + TimelineUrl: "TimelineUrl", +} as const; -export type TombstoneRefUrlType = - (typeof TombstoneRefUrlType)[keyof typeof TombstoneRefUrlType] +export type TombstoneRefUrlType = (typeof TombstoneRefUrlType)[keyof typeof TombstoneRefUrlType]; export const TombstoneRefUrlType = { - ExternalUrl: 'ExternalUrl', -} as const + ExternalUrl: "ExternalUrl", +} as const; export interface TombstoneRef { - type?: TombstoneRefType - url?: string - urlType?: TombstoneRefUrlType + type?: TombstoneRefType; + url?: string; + urlType?: TombstoneRefUrlType; } export interface TombstoneEntity { - fromIndex?: number - ref?: TombstoneRef - toIndex?: number + fromIndex?: number; + ref?: TombstoneRef; + toIndex?: number; } export interface TombstoneRichText { - entities?: TombstoneEntity[] - rtl?: boolean - text?: string + entities?: TombstoneEntity[]; + rtl?: boolean; + text?: string; } export interface TombstoneInfo { - richText?: TombstoneRichText - text?: string + richText?: TombstoneRichText; + text?: string; } export interface TimelineTombstone { - __typename?: TypeName - itemType?: ContentItemType - tombstoneDisplayType?: TimelineTombstoneTombstoneDisplayType - tombstoneInfo?: TombstoneInfo + __typename?: TypeName; + itemType?: ContentItemType; + tombstoneDisplayType?: TimelineTombstoneTombstoneDisplayType; + tombstoneInfo?: TombstoneInfo; } export interface TrendImage { - url?: string + url?: string; } export interface ThumbnailImage { - original_img_height?: number - original_img_url?: string - original_img_width?: number + original_img_height?: number; + original_img_url?: string; + original_img_width?: number; } export interface TrendMetadata { - url?: SocialContextLandingUrl + url?: SocialContextLandingUrl; } export interface TimelineTrend { - __typename: TypeName - images?: TrendImage[] - is_ai_trend?: boolean - itemType?: ContentItemType - name: string - social_context?: SocialContextUnion - thumbnail_image?: ThumbnailImage - trend_metadata: TrendMetadata - trend_url: SocialContextLandingUrl + __typename: TypeName; + images?: TrendImage[]; + is_ai_trend?: boolean; + itemType?: ContentItemType; + name: string; + social_context?: SocialContextUnion; + thumbnail_image?: ThumbnailImage; + trend_metadata: TrendMetadata; + trend_url: SocialContextLandingUrl; } export interface RichMessage { - rtl?: boolean - text?: string + rtl?: boolean; + text?: string; } -export type NotificationTemplateFromUsersItem = { [key: string]: unknown } +export type NotificationTemplateFromUsersItem = { [key: string]: unknown }; -export type NotificationTemplateTargetObjectsItem = { [key: string]: unknown } +export type NotificationTemplateTargetObjectsItem = { [key: string]: unknown }; export interface NotificationTemplate { - __typename?: TypeName - from_users?: NotificationTemplateFromUsersItem[] - target_objects?: NotificationTemplateTargetObjectsItem[] + __typename?: TypeName; + from_users?: NotificationTemplateFromUsersItem[]; + target_objects?: NotificationTemplateTargetObjectsItem[]; } export interface TimelineNotification { - __typename: TypeName - id: string - itemType: ContentItemType - notification_icon: string - notification_url: SocialContextLandingUrl - rich_message: RichMessage - template: NotificationTemplate - timestamp_ms: string + __typename: TypeName; + id: string; + itemType: ContentItemType; + notification_icon: string; + notification_url: SocialContextLandingUrl; + rich_message: RichMessage; + template: NotificationTemplate; + timestamp_ms: string; } export type ItemContentUnion = @@ -1922,296 +1898,288 @@ export type ItemContentUnion = | TimelineCommunity | TimelineTombstone | TimelineTrend - | TimelineNotification + | TimelineNotification; -export type TimelineTimelineItemFeedbackInfo = { [key: string]: unknown } +export type TimelineTimelineItemFeedbackInfo = { [key: string]: unknown }; export interface TimelineTimelineItem { - __typename: TypeName - clientEventInfo?: ClientEventInfo - entryType: ContentEntryType - feedbackInfo?: TimelineTimelineItemFeedbackInfo - itemContent: ItemContentUnion + __typename: TypeName; + clientEventInfo?: ClientEventInfo; + entryType: ContentEntryType; + feedbackInfo?: TimelineTimelineItemFeedbackInfo; + itemContent: ItemContentUnion; } -export type DisplayType = (typeof DisplayType)[keyof typeof DisplayType] +export type DisplayType = (typeof DisplayType)[keyof typeof DisplayType]; export const DisplayType = { - Vertical: 'Vertical', - VerticalConversation: 'VerticalConversation', - VerticalGrid: 'VerticalGrid', - Carousel: 'Carousel', -} as const + Vertical: "Vertical", + VerticalConversation: "VerticalConversation", + VerticalGrid: "VerticalGrid", + Carousel: "Carousel", +} as const; export interface FeedbackInfo { - clientEventInfo?: ClientEventInfo - feedbackKeys?: string[] + clientEventInfo?: ClientEventInfo; + feedbackKeys?: string[]; } export interface ModuleEntry { - clientEventInfo?: ClientEventInfo - feedbackInfo?: FeedbackInfo - itemContent: ItemContentUnion + clientEventInfo?: ClientEventInfo; + feedbackInfo?: FeedbackInfo; + itemContent: ItemContentUnion; } export interface ModuleItem { - dispensable?: boolean + dispensable?: boolean; /** @pattern ^(([a-zA-Z]+|[0-9]+|[0-9a-f]+)(-|$))+ */ - entryId: string - item: ModuleEntry + entryId: string; + item: ModuleEntry; } -export type TimelineTimelineModuleFooter = { [key: string]: unknown } +export type TimelineTimelineModuleFooter = { [key: string]: unknown }; -export type TimelineTimelineModuleHeader = { [key: string]: unknown } +export type TimelineTimelineModuleHeader = { [key: string]: unknown }; -export type TimelineTimelineModuleMetadata = { [key: string]: unknown } +export type TimelineTimelineModuleMetadata = { [key: string]: unknown }; export interface TimelineTimelineModule { - __typename: TypeName - clientEventInfo?: ClientEventInfo - displayType: DisplayType - entryType: ContentEntryType - feedbackInfo?: FeedbackInfo - footer?: TimelineTimelineModuleFooter - header?: TimelineTimelineModuleHeader - items?: ModuleItem[] - metadata?: TimelineTimelineModuleMetadata + __typename: TypeName; + clientEventInfo?: ClientEventInfo; + displayType: DisplayType; + entryType: ContentEntryType; + feedbackInfo?: FeedbackInfo; + footer?: TimelineTimelineModuleFooter; + header?: TimelineTimelineModuleHeader; + items?: ModuleItem[]; + metadata?: TimelineTimelineModuleMetadata; } -export type ContentUnion = - | TimelineTimelineItem - | TimelineTimelineModule - | TimelineTimelineCursor +export type ContentUnion = TimelineTimelineItem | TimelineTimelineModule | TimelineTimelineCursor; export interface TimelineAddEntry { - content: ContentUnion + content: ContentUnion; /** @pattern ^(([a-zA-Z]+|[0-9]+|[0-9a-f]+)(-|$))+ */ - entryId: string + entryId: string; /** @pattern [0-9]+$ */ - sortIndex: string + sortIndex: string; } -export type InstructionType = - (typeof InstructionType)[keyof typeof InstructionType] +export type InstructionType = (typeof InstructionType)[keyof typeof InstructionType]; export const InstructionType = { - TimelineAddEntries: 'TimelineAddEntries', - TimelineAddToModule: 'TimelineAddToModule', - TimelineClearCache: 'TimelineClearCache', - TimelinePinEntry: 'TimelinePinEntry', - TimelineReplaceEntry: 'TimelineReplaceEntry', - TimelineShowAlert: 'TimelineShowAlert', - TimelineTerminateTimeline: 'TimelineTerminateTimeline', - TimelineShowCover: 'TimelineShowCover', - TimelineClearEntriesUnreadState: 'TimelineClearEntriesUnreadState', - TimelineMarkEntriesUnreadGreaterThanSortIndex: - 'TimelineMarkEntriesUnreadGreaterThanSortIndex', -} as const + TimelineAddEntries: "TimelineAddEntries", + TimelineAddToModule: "TimelineAddToModule", + TimelineClearCache: "TimelineClearCache", + TimelinePinEntry: "TimelinePinEntry", + TimelineReplaceEntry: "TimelineReplaceEntry", + TimelineShowAlert: "TimelineShowAlert", + TimelineTerminateTimeline: "TimelineTerminateTimeline", + TimelineShowCover: "TimelineShowCover", + TimelineClearEntriesUnreadState: "TimelineClearEntriesUnreadState", + TimelineMarkEntriesUnreadGreaterThanSortIndex: "TimelineMarkEntriesUnreadGreaterThanSortIndex", +} as const; export interface TimelineAddEntries { - entries: TimelineAddEntry[] - type: InstructionType + entries: TimelineAddEntry[]; + type: InstructionType; } export interface TimelineAddToModule { - moduleEntryId: string - moduleItems: ModuleItem[] - prepend?: boolean - type: InstructionType + moduleEntryId: string; + moduleItems: ModuleItem[]; + prepend?: boolean; + type: InstructionType; } export interface TimelineClearCache { - type: InstructionType + type: InstructionType; } export interface TimelinePinEntry { - entry: TimelineAddEntry - type: InstructionType + entry: TimelineAddEntry; + type: InstructionType; } export interface TimelineReplaceEntry { - entry: TimelineAddEntry - entry_id_to_replace: string - type: InstructionType + entry: TimelineAddEntry; + entry_id_to_replace: string; + type: InstructionType; } export type TimelineShowAlertAlertType = - (typeof TimelineShowAlertAlertType)[keyof typeof TimelineShowAlertAlertType] + (typeof TimelineShowAlertAlertType)[keyof typeof TimelineShowAlertAlertType]; export const TimelineShowAlertAlertType = { - NewTweets: 'NewTweets', -} as const + NewTweets: "NewTweets", +} as const; export type TimelineShowAlertDisplayLocation = - (typeof TimelineShowAlertDisplayLocation)[keyof typeof TimelineShowAlertDisplayLocation] + (typeof TimelineShowAlertDisplayLocation)[keyof typeof TimelineShowAlertDisplayLocation]; export const TimelineShowAlertDisplayLocation = { - Top: 'Top', -} as const + Top: "Top", +} as const; -export type TimelineShowAlertColorConfig = { [key: string]: unknown } +export type TimelineShowAlertColorConfig = { [key: string]: unknown }; -export type TimelineShowAlertIconDisplayInfo = { [key: string]: unknown } +export type TimelineShowAlertIconDisplayInfo = { [key: string]: unknown }; -export type TimelineShowAlertRichTextEntitiesItem = { [key: string]: unknown } +export type TimelineShowAlertRichTextEntitiesItem = { [key: string]: unknown }; export type TimelineShowAlertRichText = { - entities?: TimelineShowAlertRichTextEntitiesItem[] - text?: string -} + entities?: TimelineShowAlertRichTextEntitiesItem[]; + text?: string; +}; export interface TimelineShowAlert { - alertType?: TimelineShowAlertAlertType - clientEventInfo?: ClientEventInfo - colorConfig?: TimelineShowAlertColorConfig - displayDurationMs?: number - displayLocation?: TimelineShowAlertDisplayLocation - iconDisplayInfo?: TimelineShowAlertIconDisplayInfo - richText: TimelineShowAlertRichText - triggerDelayMs?: number - type: InstructionType - usersResults: UserResults[] + alertType?: TimelineShowAlertAlertType; + clientEventInfo?: ClientEventInfo; + colorConfig?: TimelineShowAlertColorConfig; + displayDurationMs?: number; + displayLocation?: TimelineShowAlertDisplayLocation; + iconDisplayInfo?: TimelineShowAlertIconDisplayInfo; + richText: TimelineShowAlertRichText; + triggerDelayMs?: number; + type: InstructionType; + usersResults: UserResults[]; } export type TimelineTerminateTimelineDirection = - (typeof TimelineTerminateTimelineDirection)[keyof typeof TimelineTerminateTimelineDirection] + (typeof TimelineTerminateTimelineDirection)[keyof typeof TimelineTerminateTimelineDirection]; export const TimelineTerminateTimelineDirection = { - Top: 'Top', - Bottom: 'Bottom', - TopAndBottom: 'TopAndBottom', -} as const + Top: "Top", + Bottom: "Bottom", + TopAndBottom: "TopAndBottom", +} as const; export interface TimelineTerminateTimeline { - direction: TimelineTerminateTimelineDirection - type: InstructionType + direction: TimelineTerminateTimelineDirection; + type: InstructionType; } export type TimelineHalfCoverHalfCoverDisplayType = - (typeof TimelineHalfCoverHalfCoverDisplayType)[keyof typeof TimelineHalfCoverHalfCoverDisplayType] + (typeof TimelineHalfCoverHalfCoverDisplayType)[keyof typeof TimelineHalfCoverHalfCoverDisplayType]; export const TimelineHalfCoverHalfCoverDisplayType = { - Cover: 'Cover', -} as const + Cover: "Cover", +} as const; export interface Callback { - endpoint: string + endpoint: string; } -export type CoverCtaButtonStyle = - (typeof CoverCtaButtonStyle)[keyof typeof CoverCtaButtonStyle] +export type CoverCtaButtonStyle = (typeof CoverCtaButtonStyle)[keyof typeof CoverCtaButtonStyle]; export const CoverCtaButtonStyle = { - Primary: 'Primary', -} as const + Primary: "Primary", +} as const; export type CtaClientEventInfoAction = - (typeof CtaClientEventInfoAction)[keyof typeof CtaClientEventInfoAction] + (typeof CtaClientEventInfoAction)[keyof typeof CtaClientEventInfoAction]; export const CtaClientEventInfoAction = { - primary_cta: 'primary_cta', -} as const + primary_cta: "primary_cta", +} as const; export interface CtaClientEventInfo { - action: CtaClientEventInfoAction + action: CtaClientEventInfoAction; } export type TimelineCoverBehaviorType = - (typeof TimelineCoverBehaviorType)[keyof typeof TimelineCoverBehaviorType] + (typeof TimelineCoverBehaviorType)[keyof typeof TimelineCoverBehaviorType]; export const TimelineCoverBehaviorType = { - TimelineCoverBehaviorDismiss: 'TimelineCoverBehaviorDismiss', - TimelineCoverBehaviorNavigate: 'TimelineCoverBehaviorNavigate', -} as const + TimelineCoverBehaviorDismiss: "TimelineCoverBehaviorDismiss", + TimelineCoverBehaviorNavigate: "TimelineCoverBehaviorNavigate", +} as const; export type TimelineCoverBehaviorUrlUrlType = - (typeof TimelineCoverBehaviorUrlUrlType)[keyof typeof TimelineCoverBehaviorUrlUrlType] + (typeof TimelineCoverBehaviorUrlUrlType)[keyof typeof TimelineCoverBehaviorUrlUrlType]; export const TimelineCoverBehaviorUrlUrlType = { - ExternalUrl: 'ExternalUrl', -} as const + ExternalUrl: "ExternalUrl", +} as const; export interface TimelineCoverBehaviorUrl { - url: string - url_type: TimelineCoverBehaviorUrlUrlType + url: string; + url_type: TimelineCoverBehaviorUrlUrlType; } export interface TimelineCoverBehavior { - type: TimelineCoverBehaviorType - url?: TimelineCoverBehaviorUrl + type: TimelineCoverBehaviorType; + url?: TimelineCoverBehaviorUrl; } export interface CoverCta { - Text?: string - buttonStyle?: CoverCtaButtonStyle - callbacks: Callback[] - clientEventInfo: CtaClientEventInfo - ctaBehavior: TimelineCoverBehavior + Text?: string; + buttonStyle?: CoverCtaButtonStyle; + callbacks: Callback[]; + clientEventInfo: CtaClientEventInfo; + ctaBehavior: TimelineCoverBehavior; } -export type TextEntityRefType = - (typeof TextEntityRefType)[keyof typeof TextEntityRefType] +export type TextEntityRefType = (typeof TextEntityRefType)[keyof typeof TextEntityRefType]; export const TextEntityRefType = { - TimelineUrl: 'TimelineUrl', -} as const + TimelineUrl: "TimelineUrl", +} as const; -export type TextEntityRefUrlType = - (typeof TextEntityRefUrlType)[keyof typeof TextEntityRefUrlType] +export type TextEntityRefUrlType = (typeof TextEntityRefUrlType)[keyof typeof TextEntityRefUrlType]; export const TextEntityRefUrlType = { - ExternalUrl: 'ExternalUrl', -} as const + ExternalUrl: "ExternalUrl", +} as const; export interface TextEntityRef { - type: TextEntityRefType - url: string - urlType: TextEntityRefUrlType + type: TextEntityRefType; + url: string; + urlType: TextEntityRefUrlType; } export interface TextEntity { - fromIndex: number - ref: TextEntityRef - toIndex: number + fromIndex: number; + ref: TextEntityRef; + toIndex: number; } export interface Text { - entities: TextEntity[] - text: string + entities: TextEntity[]; + text: string; } export type TimelineHalfCoverType = - (typeof TimelineHalfCoverType)[keyof typeof TimelineHalfCoverType] + (typeof TimelineHalfCoverType)[keyof typeof TimelineHalfCoverType]; export const TimelineHalfCoverType = { - TimelineHalfCover: 'TimelineHalfCover', -} as const + TimelineHalfCover: "TimelineHalfCover", +} as const; export interface TimelineHalfCover { - dismissible: boolean - halfCoverDisplayType: TimelineHalfCoverHalfCoverDisplayType - impressionCallbacks: Callback[] - primaryCoverCta: CoverCta - primaryText: Text - secondaryText: Text - type: TimelineHalfCoverType + dismissible: boolean; + halfCoverDisplayType: TimelineHalfCoverHalfCoverDisplayType; + impressionCallbacks: Callback[]; + primaryCoverCta: CoverCta; + primaryText: Text; + secondaryText: Text; + type: TimelineHalfCoverType; } export interface TimelineShowCover { - clientEventInfo: ClientEventInfo - cover: TimelineHalfCover - type: InstructionType + clientEventInfo: ClientEventInfo; + cover: TimelineHalfCover; + type: InstructionType; } export interface TimelineClearEntriesUnreadState { - type: InstructionType + type: InstructionType; } export interface TimelineMarkEntriesUnreadGreaterThanSortIndex { /** @pattern [0-9]+$ */ - sort_index?: string - type: InstructionType + sort_index?: string; + type: InstructionType; } export type InstructionUnion = @@ -2224,626 +2192,598 @@ export type InstructionUnion = | TimelineTerminateTimeline | TimelineShowCover | TimelineClearEntriesUnreadState - | TimelineMarkEntriesUnreadGreaterThanSortIndex + | TimelineMarkEntriesUnreadGreaterThanSortIndex; -export type TimelineMetadata = { [key: string]: unknown } +export type TimelineMetadata = { [key: string]: unknown }; -export type TimelineResponseObjects = { [key: string]: unknown } +export type TimelineResponseObjects = { [key: string]: unknown }; export interface Timeline { - instructions: InstructionUnion[] - metadata?: TimelineMetadata - responseObjects?: TimelineResponseObjects + instructions: InstructionUnion[]; + metadata?: TimelineMetadata; + responseObjects?: TimelineResponseObjects; } export interface TimelineResult { - id?: string - timeline?: Timeline + id?: string; + timeline?: Timeline; } export interface ListTweetsTimelineList { - tweets_timeline: TimelineResult + tweets_timeline: TimelineResult; } export interface ListTweetsTimelineData { - list: ListTweetsTimelineList + list: ListTweetsTimelineList; } export interface Tracing { /** @pattern ^[0-9a-f]{16}$ */ - trace_id: string + trace_id: string; } export interface ErrorExtensions { - code: number - kind: string - name: string - retry_after?: number - source: string - tracing: Tracing + code: number; + kind: string; + name: string; + retry_after?: number; + source: string; + tracing: Tracing; } export interface Location { - column: number - line: number + column: number; + line: number; } export interface ErrorResponse { - code: number - extensions: ErrorExtensions - kind: string - locations: Location[] - message: string - name: string - path: (string | number)[] - retry_after?: number - source: string - tracing: Tracing + code: number; + extensions: ErrorExtensions; + kind: string; + locations: Location[]; + message: string; + name: string; + path: (string | number)[]; + retry_after?: number; + source: string; + tracing: Tracing; } export interface ListLatestTweetsTimelineResponse { - data: ListTweetsTimelineData - errors?: ErrorResponse[] + data: ListTweetsTimelineData; + errors?: ErrorResponse[]; } export interface SearchTimeline { - timeline: Timeline + timeline: Timeline; } export interface SearchByRawQuery { - search_timeline: SearchTimeline + search_timeline: SearchTimeline; } export interface SearchTimelineData { - search_by_raw_query: SearchByRawQuery + search_by_raw_query: SearchByRawQuery; } export interface SearchTimelineResponse { - data: SearchTimelineData - errors?: ErrorResponse[] + data: SearchTimelineData; + errors?: ErrorResponse[]; } export interface TweetDetailResponseData { - threaded_conversation_with_injections_v2?: Timeline + threaded_conversation_with_injections_v2?: Timeline; } export interface TweetDetailResponse { - data: TweetDetailResponseData - errors?: ErrorResponse[] + data: TweetDetailResponseData; + errors?: ErrorResponse[]; } export interface UserResponseData { - user?: UserResults + user?: UserResults; } export interface UserResponse { - data: UserResponseData - errors?: ErrorResponse[] + data: UserResponseData; + errors?: ErrorResponse[]; } export interface UserTweetsResultV1 { - __typename: TypeName - timeline: TimelineResult + __typename: TypeName; + timeline: TimelineResult; } export interface UserTweetsUser { - result: UserTweetsResultV1 + result: UserTweetsResultV1; } export interface UserTweetsData { - user?: UserTweetsUser + user?: UserTweetsUser; } export interface UserTweetsResponse { - data: UserTweetsData - errors?: ErrorResponse[] + data: UserTweetsData; + errors?: ErrorResponse[]; } export type GetListLatestTweetsTimelineParams = { variables: { - count: number - listId: string - } + count: number; + listId: string; + }; features: { - articles_preview_enabled: boolean - c9s_tweet_anatomy_moderator_badge_enabled: boolean - communities_web_enable_tweet_community_results_fetch: boolean - creator_subscriptions_quote_tweet_preview_enabled: boolean - creator_subscriptions_tweet_preview_api_enabled: boolean - freedom_of_speech_not_reach_fetch_enabled: boolean - graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean - longform_notetweets_consumption_enabled: boolean - longform_notetweets_inline_media_enabled: boolean - longform_notetweets_rich_text_read_enabled: boolean - payments_enabled: boolean - premium_content_api_read_enabled: boolean - profile_label_improvements_pcf_label_in_post_enabled: boolean - responsive_web_edit_tweet_api_enabled: boolean - responsive_web_enhance_cards_enabled: boolean - responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean - responsive_web_graphql_timeline_navigation_enabled: boolean - responsive_web_grok_analysis_button_from_backend: boolean - responsive_web_grok_analyze_button_fetch_trends_enabled: boolean - responsive_web_grok_analyze_post_followups_enabled: boolean - responsive_web_grok_community_note_auto_translation_is_enabled: boolean - responsive_web_grok_image_annotation_enabled: boolean - responsive_web_grok_imagine_annotation_enabled: boolean - responsive_web_grok_share_attachment_enabled: boolean - responsive_web_grok_show_grok_translated_post: boolean - responsive_web_jetfuel_frame: boolean - responsive_web_profile_redirect_enabled: boolean - responsive_web_twitter_article_tweet_consumption_enabled: boolean - rweb_tipjar_consumption_enabled: boolean - rweb_video_screen_enabled: boolean - standardized_nudges_misinfo: boolean - tweet_awards_web_tipping_enabled: boolean - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean - verified_phone_label_enabled: boolean - view_counts_everywhere_api_enabled: boolean - } -} + articles_preview_enabled: boolean; + c9s_tweet_anatomy_moderator_badge_enabled: boolean; + communities_web_enable_tweet_community_results_fetch: boolean; + creator_subscriptions_quote_tweet_preview_enabled: boolean; + creator_subscriptions_tweet_preview_api_enabled: boolean; + freedom_of_speech_not_reach_fetch_enabled: boolean; + graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean; + longform_notetweets_consumption_enabled: boolean; + longform_notetweets_inline_media_enabled: boolean; + longform_notetweets_rich_text_read_enabled: boolean; + payments_enabled: boolean; + premium_content_api_read_enabled: boolean; + profile_label_improvements_pcf_label_in_post_enabled: boolean; + responsive_web_edit_tweet_api_enabled: boolean; + responsive_web_enhance_cards_enabled: boolean; + responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean; + responsive_web_graphql_timeline_navigation_enabled: boolean; + responsive_web_grok_analysis_button_from_backend: boolean; + responsive_web_grok_analyze_button_fetch_trends_enabled: boolean; + responsive_web_grok_analyze_post_followups_enabled: boolean; + responsive_web_grok_community_note_auto_translation_is_enabled: boolean; + responsive_web_grok_image_annotation_enabled: boolean; + responsive_web_grok_imagine_annotation_enabled: boolean; + responsive_web_grok_share_attachment_enabled: boolean; + responsive_web_grok_show_grok_translated_post: boolean; + responsive_web_jetfuel_frame: boolean; + responsive_web_profile_redirect_enabled: boolean; + responsive_web_twitter_article_tweet_consumption_enabled: boolean; + rweb_tipjar_consumption_enabled: boolean; + rweb_video_screen_enabled: boolean; + standardized_nudges_misinfo: boolean; + tweet_awards_web_tipping_enabled: boolean; + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean; + verified_phone_label_enabled: boolean; + view_counts_everywhere_api_enabled: boolean; + }; +}; export type GetSearchTimelineParams = { variables: { - count: number - product: string - querySource: string - rawQuery: string - } + count: number; + product: string; + querySource: string; + rawQuery: string; + }; features: { - articles_preview_enabled: boolean - c9s_tweet_anatomy_moderator_badge_enabled: boolean - communities_web_enable_tweet_community_results_fetch: boolean - content_disclosure_ai_generated_indicator_enabled: boolean - content_disclosure_indicator_enabled: boolean - creator_subscriptions_tweet_preview_api_enabled: boolean - freedom_of_speech_not_reach_fetch_enabled: boolean - graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean - longform_notetweets_consumption_enabled: boolean - longform_notetweets_inline_media_enabled: boolean - longform_notetweets_rich_text_read_enabled: boolean - post_ctas_fetch_enabled: boolean - premium_content_api_read_enabled: boolean - profile_label_improvements_pcf_label_in_post_enabled: boolean - responsive_web_edit_tweet_api_enabled: boolean - responsive_web_enhance_cards_enabled: boolean - responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean - responsive_web_graphql_timeline_navigation_enabled: boolean - responsive_web_grok_analysis_button_from_backend: boolean - responsive_web_grok_analyze_button_fetch_trends_enabled: boolean - responsive_web_grok_analyze_post_followups_enabled: boolean - responsive_web_grok_annotations_enabled: boolean - responsive_web_grok_community_note_auto_translation_is_enabled: boolean - responsive_web_grok_image_annotation_enabled: boolean - responsive_web_grok_imagine_annotation_enabled: boolean - responsive_web_grok_share_attachment_enabled: boolean - responsive_web_grok_show_grok_translated_post: boolean - responsive_web_jetfuel_frame: boolean - responsive_web_profile_redirect_enabled: boolean - responsive_web_twitter_article_tweet_consumption_enabled: boolean - rweb_cashtags_composer_attachment_enabled: boolean - rweb_cashtags_enabled: boolean - rweb_conversational_replies_downvote_enabled: boolean - rweb_tipjar_consumption_enabled: boolean - rweb_video_screen_enabled: boolean - standardized_nudges_misinfo: boolean - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean - verified_phone_label_enabled: boolean - view_counts_everywhere_api_enabled: boolean - } -} + articles_preview_enabled: boolean; + c9s_tweet_anatomy_moderator_badge_enabled: boolean; + communities_web_enable_tweet_community_results_fetch: boolean; + content_disclosure_ai_generated_indicator_enabled: boolean; + content_disclosure_indicator_enabled: boolean; + creator_subscriptions_tweet_preview_api_enabled: boolean; + freedom_of_speech_not_reach_fetch_enabled: boolean; + graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean; + longform_notetweets_consumption_enabled: boolean; + longform_notetweets_inline_media_enabled: boolean; + longform_notetweets_rich_text_read_enabled: boolean; + post_ctas_fetch_enabled: boolean; + premium_content_api_read_enabled: boolean; + profile_label_improvements_pcf_label_in_post_enabled: boolean; + responsive_web_edit_tweet_api_enabled: boolean; + responsive_web_enhance_cards_enabled: boolean; + responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean; + responsive_web_graphql_timeline_navigation_enabled: boolean; + responsive_web_grok_analysis_button_from_backend: boolean; + responsive_web_grok_analyze_button_fetch_trends_enabled: boolean; + responsive_web_grok_analyze_post_followups_enabled: boolean; + responsive_web_grok_annotations_enabled: boolean; + responsive_web_grok_community_note_auto_translation_is_enabled: boolean; + responsive_web_grok_image_annotation_enabled: boolean; + responsive_web_grok_imagine_annotation_enabled: boolean; + responsive_web_grok_share_attachment_enabled: boolean; + responsive_web_grok_show_grok_translated_post: boolean; + responsive_web_jetfuel_frame: boolean; + responsive_web_profile_redirect_enabled: boolean; + responsive_web_twitter_article_tweet_consumption_enabled: boolean; + rweb_cashtags_composer_attachment_enabled: boolean; + rweb_cashtags_enabled: boolean; + rweb_conversational_replies_downvote_enabled: boolean; + rweb_tipjar_consumption_enabled: boolean; + rweb_video_screen_enabled: boolean; + standardized_nudges_misinfo: boolean; + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean; + verified_phone_label_enabled: boolean; + view_counts_everywhere_api_enabled: boolean; + }; +}; export type GetTweetDetailParams = { variables: { - focalTweetId: string - includePromotedContent: boolean - rankingMode: string - referrer: string - withBirdwatchNotes: boolean - withCommunity: boolean - withQuickPromoteEligibilityTweetFields: boolean - withVoice: boolean - with_rux_injections: boolean - } + focalTweetId: string; + includePromotedContent: boolean; + rankingMode: string; + referrer: string; + withBirdwatchNotes: boolean; + withCommunity: boolean; + withQuickPromoteEligibilityTweetFields: boolean; + withVoice: boolean; + with_rux_injections: boolean; + }; features: { - articles_preview_enabled: boolean - c9s_tweet_anatomy_moderator_badge_enabled: boolean - communities_web_enable_tweet_community_results_fetch: boolean - content_disclosure_ai_generated_indicator_enabled: boolean - content_disclosure_indicator_enabled: boolean - creator_subscriptions_tweet_preview_api_enabled: boolean - freedom_of_speech_not_reach_fetch_enabled: boolean - graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean - longform_notetweets_consumption_enabled: boolean - longform_notetweets_inline_media_enabled: boolean - longform_notetweets_rich_text_read_enabled: boolean - post_ctas_fetch_enabled: boolean - premium_content_api_read_enabled: boolean - profile_label_improvements_pcf_label_in_post_enabled: boolean - responsive_web_edit_tweet_api_enabled: boolean - responsive_web_enhance_cards_enabled: boolean - responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean - responsive_web_graphql_timeline_navigation_enabled: boolean - responsive_web_grok_analysis_button_from_backend: boolean - responsive_web_grok_analyze_button_fetch_trends_enabled: boolean - responsive_web_grok_analyze_post_followups_enabled: boolean - responsive_web_grok_annotations_enabled: boolean - responsive_web_grok_community_note_auto_translation_is_enabled: boolean - responsive_web_grok_image_annotation_enabled: boolean - responsive_web_grok_imagine_annotation_enabled: boolean - responsive_web_grok_share_attachment_enabled: boolean - responsive_web_grok_show_grok_translated_post: boolean - responsive_web_jetfuel_frame: boolean - responsive_web_profile_redirect_enabled: boolean - responsive_web_twitter_article_tweet_consumption_enabled: boolean - rweb_cashtags_composer_attachment_enabled: boolean - rweb_cashtags_enabled: boolean - rweb_conversational_replies_downvote_enabled: boolean - rweb_tipjar_consumption_enabled: boolean - rweb_video_screen_enabled: boolean - standardized_nudges_misinfo: boolean - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean - verified_phone_label_enabled: boolean - view_counts_everywhere_api_enabled: boolean - } + articles_preview_enabled: boolean; + c9s_tweet_anatomy_moderator_badge_enabled: boolean; + communities_web_enable_tweet_community_results_fetch: boolean; + content_disclosure_ai_generated_indicator_enabled: boolean; + content_disclosure_indicator_enabled: boolean; + creator_subscriptions_tweet_preview_api_enabled: boolean; + freedom_of_speech_not_reach_fetch_enabled: boolean; + graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean; + longform_notetweets_consumption_enabled: boolean; + longform_notetweets_inline_media_enabled: boolean; + longform_notetweets_rich_text_read_enabled: boolean; + post_ctas_fetch_enabled: boolean; + premium_content_api_read_enabled: boolean; + profile_label_improvements_pcf_label_in_post_enabled: boolean; + responsive_web_edit_tweet_api_enabled: boolean; + responsive_web_enhance_cards_enabled: boolean; + responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean; + responsive_web_graphql_timeline_navigation_enabled: boolean; + responsive_web_grok_analysis_button_from_backend: boolean; + responsive_web_grok_analyze_button_fetch_trends_enabled: boolean; + responsive_web_grok_analyze_post_followups_enabled: boolean; + responsive_web_grok_annotations_enabled: boolean; + responsive_web_grok_community_note_auto_translation_is_enabled: boolean; + responsive_web_grok_image_annotation_enabled: boolean; + responsive_web_grok_imagine_annotation_enabled: boolean; + responsive_web_grok_share_attachment_enabled: boolean; + responsive_web_grok_show_grok_translated_post: boolean; + responsive_web_jetfuel_frame: boolean; + responsive_web_profile_redirect_enabled: boolean; + responsive_web_twitter_article_tweet_consumption_enabled: boolean; + rweb_cashtags_composer_attachment_enabled: boolean; + rweb_cashtags_enabled: boolean; + rweb_conversational_replies_downvote_enabled: boolean; + rweb_tipjar_consumption_enabled: boolean; + rweb_video_screen_enabled: boolean; + standardized_nudges_misinfo: boolean; + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean; + verified_phone_label_enabled: boolean; + view_counts_everywhere_api_enabled: boolean; + }; fieldToggles: { - withArticlePlainText: boolean - withArticleRichContentState: boolean - withArticleSummaryText: boolean - withArticleVoiceOver: boolean - withDisallowedReplyControls: boolean - withGrokAnalyze: boolean - } -} + withArticlePlainText: boolean; + withArticleRichContentState: boolean; + withArticleSummaryText: boolean; + withArticleVoiceOver: boolean; + withDisallowedReplyControls: boolean; + withGrokAnalyze: boolean; + }; +}; export type GetUserByScreenNameParams = { variables: { - screen_name: string - } + screen_name: string; + }; features: { - creator_subscriptions_tweet_preview_api_enabled: boolean - hidden_profile_subscriptions_enabled: boolean - highlights_tweets_tab_ui_enabled: boolean - profile_label_improvements_pcf_label_in_post_enabled: boolean - responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean - responsive_web_graphql_timeline_navigation_enabled: boolean - responsive_web_profile_redirect_enabled: boolean - responsive_web_twitter_article_notes_tab_enabled: boolean - rweb_tipjar_consumption_enabled: boolean - subscriptions_feature_can_gift_premium: boolean - subscriptions_verification_info_is_identity_verified_enabled: boolean - subscriptions_verification_info_verified_since_enabled: boolean - verified_phone_label_enabled: boolean - } + creator_subscriptions_tweet_preview_api_enabled: boolean; + hidden_profile_subscriptions_enabled: boolean; + highlights_tweets_tab_ui_enabled: boolean; + profile_label_improvements_pcf_label_in_post_enabled: boolean; + responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean; + responsive_web_graphql_timeline_navigation_enabled: boolean; + responsive_web_profile_redirect_enabled: boolean; + responsive_web_twitter_article_notes_tab_enabled: boolean; + rweb_tipjar_consumption_enabled: boolean; + subscriptions_feature_can_gift_premium: boolean; + subscriptions_verification_info_is_identity_verified_enabled: boolean; + subscriptions_verification_info_verified_since_enabled: boolean; + verified_phone_label_enabled: boolean; + }; fieldToggles: { - withAuxiliaryUserLabels: boolean - withPayments: boolean - } -} + withAuxiliaryUserLabels: boolean; + withPayments: boolean; + }; +}; export type GetUserTweetsParams = { variables: { - count: number - includePromotedContent: boolean - userId: string - withQuickPromoteEligibilityTweetFields: boolean - withVoice: boolean - } + count: number; + includePromotedContent: boolean; + userId: string; + withQuickPromoteEligibilityTweetFields: boolean; + withVoice: boolean; + }; features: { - articles_preview_enabled: boolean - c9s_tweet_anatomy_moderator_badge_enabled: boolean - communities_web_enable_tweet_community_results_fetch: boolean - content_disclosure_ai_generated_indicator_enabled: boolean - content_disclosure_indicator_enabled: boolean - creator_subscriptions_tweet_preview_api_enabled: boolean - freedom_of_speech_not_reach_fetch_enabled: boolean - graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean - longform_notetweets_consumption_enabled: boolean - longform_notetweets_inline_media_enabled: boolean - longform_notetweets_rich_text_read_enabled: boolean - post_ctas_fetch_enabled: boolean - premium_content_api_read_enabled: boolean - profile_label_improvements_pcf_label_in_post_enabled: boolean - responsive_web_edit_tweet_api_enabled: boolean - responsive_web_enhance_cards_enabled: boolean - responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean - responsive_web_graphql_timeline_navigation_enabled: boolean - responsive_web_grok_analysis_button_from_backend: boolean - responsive_web_grok_analyze_button_fetch_trends_enabled: boolean - responsive_web_grok_analyze_post_followups_enabled: boolean - responsive_web_grok_annotations_enabled: boolean - responsive_web_grok_community_note_auto_translation_is_enabled: boolean - responsive_web_grok_image_annotation_enabled: boolean - responsive_web_grok_imagine_annotation_enabled: boolean - responsive_web_grok_share_attachment_enabled: boolean - responsive_web_grok_show_grok_translated_post: boolean - responsive_web_jetfuel_frame: boolean - responsive_web_profile_redirect_enabled: boolean - responsive_web_twitter_article_tweet_consumption_enabled: boolean - rweb_cashtags_composer_attachment_enabled: boolean - rweb_cashtags_enabled: boolean - rweb_conversational_replies_downvote_enabled: boolean - rweb_tipjar_consumption_enabled: boolean - rweb_video_screen_enabled: boolean - standardized_nudges_misinfo: boolean - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean - verified_phone_label_enabled: boolean - view_counts_everywhere_api_enabled: boolean - } + articles_preview_enabled: boolean; + c9s_tweet_anatomy_moderator_badge_enabled: boolean; + communities_web_enable_tweet_community_results_fetch: boolean; + content_disclosure_ai_generated_indicator_enabled: boolean; + content_disclosure_indicator_enabled: boolean; + creator_subscriptions_tweet_preview_api_enabled: boolean; + freedom_of_speech_not_reach_fetch_enabled: boolean; + graphql_is_translatable_rweb_tweet_is_translatable_enabled: boolean; + longform_notetweets_consumption_enabled: boolean; + longform_notetweets_inline_media_enabled: boolean; + longform_notetweets_rich_text_read_enabled: boolean; + post_ctas_fetch_enabled: boolean; + premium_content_api_read_enabled: boolean; + profile_label_improvements_pcf_label_in_post_enabled: boolean; + responsive_web_edit_tweet_api_enabled: boolean; + responsive_web_enhance_cards_enabled: boolean; + responsive_web_graphql_skip_user_profile_image_extensions_enabled: boolean; + responsive_web_graphql_timeline_navigation_enabled: boolean; + responsive_web_grok_analysis_button_from_backend: boolean; + responsive_web_grok_analyze_button_fetch_trends_enabled: boolean; + responsive_web_grok_analyze_post_followups_enabled: boolean; + responsive_web_grok_annotations_enabled: boolean; + responsive_web_grok_community_note_auto_translation_is_enabled: boolean; + responsive_web_grok_image_annotation_enabled: boolean; + responsive_web_grok_imagine_annotation_enabled: boolean; + responsive_web_grok_share_attachment_enabled: boolean; + responsive_web_grok_show_grok_translated_post: boolean; + responsive_web_jetfuel_frame: boolean; + responsive_web_profile_redirect_enabled: boolean; + responsive_web_twitter_article_tweet_consumption_enabled: boolean; + rweb_cashtags_composer_attachment_enabled: boolean; + rweb_cashtags_enabled: boolean; + rweb_conversational_replies_downvote_enabled: boolean; + rweb_tipjar_consumption_enabled: boolean; + rweb_video_screen_enabled: boolean; + standardized_nudges_misinfo: boolean; + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: boolean; + verified_phone_label_enabled: boolean; + view_counts_everywhere_api_enabled: boolean; + }; fieldToggles: { - withArticlePlainText: boolean - } -} + withArticlePlainText: boolean; + }; +}; export type getListLatestTweetsTimelineResponse200 = { - data: ListLatestTweetsTimelineResponse - status: 200 -} + data: ListLatestTweetsTimelineResponse; + status: 200; +}; -export type getListLatestTweetsTimelineResponseSuccess = - getListLatestTweetsTimelineResponse200 & { - headers: Headers - } +export type getListLatestTweetsTimelineResponseSuccess = getListLatestTweetsTimelineResponse200 & { + headers: Headers; +}; -export type getListLatestTweetsTimelineResponse = - getListLatestTweetsTimelineResponseSuccess +export type getListLatestTweetsTimelineResponse = getListLatestTweetsTimelineResponseSuccess; export const getGetListLatestTweetsTimelineUrl = ( params: GetListLatestTweetsTimelineParams, - pathQueryId: string = 'FVWmROVvhgjRPC-4jAUh8A', + pathQueryId: string = "FVWmROVvhgjRPC-4jAUh8A", ) => { - const normalizedParams = new URLSearchParams() + const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { - normalizedParams.append(key, value === null ? 'null' : String(value)) + normalizedParams.append(key, value === null ? "null" : String(value)); } - }) + }); - const stringifiedParams = normalizedParams.toString() + const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/graphql/${pathQueryId}/ListLatestTweetsTimeline?${stringifiedParams}` - : `/graphql/${pathQueryId}/ListLatestTweetsTimeline` -} + : `/graphql/${pathQueryId}/ListLatestTweetsTimeline`; +}; /** * get tweet list of timeline */ export const getListLatestTweetsTimeline = async ( params: GetListLatestTweetsTimelineParams, - pathQueryId: string = 'FVWmROVvhgjRPC-4jAUh8A', + pathQueryId: string = "FVWmROVvhgjRPC-4jAUh8A", options?: RequestInit, ): Promise => { - const res = await fetch( - getGetListLatestTweetsTimelineUrl(params, pathQueryId), - { - ...options, - method: 'GET', - }, - ) + const res = await fetch(getGetListLatestTweetsTimelineUrl(params, pathQueryId), { + ...options, + method: "GET", + }); - const body = [204, 205, 304].includes(res.status) ? null : await res.text() + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data: getListLatestTweetsTimelineResponse['data'] = body - ? JSON.parse(body) - : {} - return { - data, - status: res.status, - headers: res.headers, - } as getListLatestTweetsTimelineResponse -} + const data: getListLatestTweetsTimelineResponse["data"] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getListLatestTweetsTimelineResponse; +}; export type getSearchTimelineResponse200 = { - data: SearchTimelineResponse - status: 200 -} + data: SearchTimelineResponse; + status: 200; +}; export type getSearchTimelineResponseSuccess = getSearchTimelineResponse200 & { - headers: Headers -} + headers: Headers; +}; -export type getSearchTimelineResponse = getSearchTimelineResponseSuccess +export type getSearchTimelineResponse = getSearchTimelineResponseSuccess; export const getGetSearchTimelineUrl = ( params: GetSearchTimelineParams, - pathQueryId: string = 'Yw6L66Pw54NHKuq4Dp7b4Q', + pathQueryId: string = "Yw6L66Pw54NHKuq4Dp7b4Q", ) => { - const normalizedParams = new URLSearchParams() + const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { - normalizedParams.append(key, value === null ? 'null' : String(value)) + normalizedParams.append(key, value === null ? "null" : String(value)); } - }) + }); - const stringifiedParams = normalizedParams.toString() + const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/graphql/${pathQueryId}/SearchTimeline?${stringifiedParams}` - : `/graphql/${pathQueryId}/SearchTimeline` -} + : `/graphql/${pathQueryId}/SearchTimeline`; +}; /** * search tweet list. product:[Top, Latest, People, Photos, Videos] */ export const getSearchTimeline = async ( params: GetSearchTimelineParams, - pathQueryId: string = 'Yw6L66Pw54NHKuq4Dp7b4Q', + pathQueryId: string = "Yw6L66Pw54NHKuq4Dp7b4Q", options?: RequestInit, ): Promise => { const res = await fetch(getGetSearchTimelineUrl(params, pathQueryId), { ...options, - method: 'GET', - }) + method: "GET", + }); - const body = [204, 205, 304].includes(res.status) ? null : await res.text() + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data: getSearchTimelineResponse['data'] = body ? JSON.parse(body) : {} - return { - data, - status: res.status, - headers: res.headers, - } as getSearchTimelineResponse -} + const data: getSearchTimelineResponse["data"] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getSearchTimelineResponse; +}; export type getTweetDetailResponse200 = { - data: TweetDetailResponse - status: 200 -} + data: TweetDetailResponse; + status: 200; +}; export type getTweetDetailResponseSuccess = getTweetDetailResponse200 & { - headers: Headers -} + headers: Headers; +}; -export type getTweetDetailResponse = getTweetDetailResponseSuccess +export type getTweetDetailResponse = getTweetDetailResponseSuccess; export const getGetTweetDetailUrl = ( params: GetTweetDetailParams, - pathQueryId: string = 'oCon7R-cgWRFy6EfZjaKfg', + pathQueryId: string = "oCon7R-cgWRFy6EfZjaKfg", ) => { - const normalizedParams = new URLSearchParams() + const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { - normalizedParams.append(key, value === null ? 'null' : String(value)) + normalizedParams.append(key, value === null ? "null" : String(value)); } - }) + }); - const stringifiedParams = normalizedParams.toString() + const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/graphql/${pathQueryId}/TweetDetail?${stringifiedParams}` - : `/graphql/${pathQueryId}/TweetDetail` -} + : `/graphql/${pathQueryId}/TweetDetail`; +}; /** * get TweetDetail */ export const getTweetDetail = async ( params: GetTweetDetailParams, - pathQueryId: string = 'oCon7R-cgWRFy6EfZjaKfg', + pathQueryId: string = "oCon7R-cgWRFy6EfZjaKfg", options?: RequestInit, ): Promise => { const res = await fetch(getGetTweetDetailUrl(params, pathQueryId), { ...options, - method: 'GET', - }) + method: "GET", + }); - const body = [204, 205, 304].includes(res.status) ? null : await res.text() + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data: getTweetDetailResponse['data'] = body ? JSON.parse(body) : {} - return { - data, - status: res.status, - headers: res.headers, - } as getTweetDetailResponse -} + const data: getTweetDetailResponse["data"] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getTweetDetailResponse; +}; export type getUserByScreenNameResponse200 = { - data: UserResponse - status: 200 -} + data: UserResponse; + status: 200; +}; -export type getUserByScreenNameResponseSuccess = - getUserByScreenNameResponse200 & { - headers: Headers - } +export type getUserByScreenNameResponseSuccess = getUserByScreenNameResponse200 & { + headers: Headers; +}; -export type getUserByScreenNameResponse = getUserByScreenNameResponseSuccess +export type getUserByScreenNameResponse = getUserByScreenNameResponseSuccess; export const getGetUserByScreenNameUrl = ( params: GetUserByScreenNameParams, - pathQueryId: string = 'IGgvgiOx4QZndDHuD3x9TQ', + pathQueryId: string = "IGgvgiOx4QZndDHuD3x9TQ", ) => { - const normalizedParams = new URLSearchParams() + const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { - normalizedParams.append(key, value === null ? 'null' : String(value)) + normalizedParams.append(key, value === null ? "null" : String(value)); } - }) + }); - const stringifiedParams = normalizedParams.toString() + const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/graphql/${pathQueryId}/UserByScreenName?${stringifiedParams}` - : `/graphql/${pathQueryId}/UserByScreenName` -} + : `/graphql/${pathQueryId}/UserByScreenName`; +}; /** * get user by screen name */ export const getUserByScreenName = async ( params: GetUserByScreenNameParams, - pathQueryId: string = 'IGgvgiOx4QZndDHuD3x9TQ', + pathQueryId: string = "IGgvgiOx4QZndDHuD3x9TQ", options?: RequestInit, ): Promise => { const res = await fetch(getGetUserByScreenNameUrl(params, pathQueryId), { ...options, - method: 'GET', - }) + method: "GET", + }); - const body = [204, 205, 304].includes(res.status) ? null : await res.text() + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data: getUserByScreenNameResponse['data'] = body ? JSON.parse(body) : {} - return { - data, - status: res.status, - headers: res.headers, - } as getUserByScreenNameResponse -} + const data: getUserByScreenNameResponse["data"] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getUserByScreenNameResponse; +}; export type getUserTweetsResponse200 = { - data: UserTweetsResponse - status: 200 -} + data: UserTweetsResponse; + status: 200; +}; export type getUserTweetsResponseSuccess = getUserTweetsResponse200 & { - headers: Headers -} + headers: Headers; +}; -export type getUserTweetsResponse = getUserTweetsResponseSuccess +export type getUserTweetsResponse = getUserTweetsResponseSuccess; export const getGetUserTweetsUrl = ( params: GetUserTweetsParams, - pathQueryId: string = '36rb3Xj3iJ64Q-9wKDjCcQ', + pathQueryId: string = "36rb3Xj3iJ64Q-9wKDjCcQ", ) => { - const normalizedParams = new URLSearchParams() + const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { - normalizedParams.append(key, value === null ? 'null' : String(value)) + normalizedParams.append(key, value === null ? "null" : String(value)); } - }) + }); - const stringifiedParams = normalizedParams.toString() + const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/graphql/${pathQueryId}/UserTweets?${stringifiedParams}` - : `/graphql/${pathQueryId}/UserTweets` -} + : `/graphql/${pathQueryId}/UserTweets`; +}; /** * get user tweets */ export const getUserTweets = async ( params: GetUserTweetsParams, - pathQueryId: string = '36rb3Xj3iJ64Q-9wKDjCcQ', + pathQueryId: string = "36rb3Xj3iJ64Q-9wKDjCcQ", options?: RequestInit, ): Promise => { const res = await fetch(getGetUserTweetsUrl(params, pathQueryId), { ...options, - method: 'GET', - }) + method: "GET", + }); - const body = [204, 205, 304].includes(res.status) ? null : await res.text() + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); - const data: getUserTweetsResponse['data'] = body ? JSON.parse(body) : {} - return { - data, - status: res.status, - headers: res.headers, - } as getUserTweetsResponse -} + const data: getUserTweetsResponse["data"] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getUserTweetsResponse; +}; diff --git a/e2e/integrations/access.test.ts b/e2e/integrations/access.test.ts index c5d0ef8..2660835 100644 --- a/e2e/integrations/access.test.ts +++ b/e2e/integrations/access.test.ts @@ -1,63 +1,53 @@ -import { expect, test } from '../fixtures' +import { expect, test } from "../fixtures"; -test('owner can open the app but missing or foreign identity is forbidden', async ({ - request, -}) => { - expect((await request.get('/')).status()).toBe(200) +test("owner can open the app but missing or foreign identity is forbidden", async ({ request }) => { + expect((await request.get("/")).status()).toBe(200); + expect((await request.get("/", { headers: { "Tailscale-User-Login": "" } })).status()).toBe(403); expect( ( - await request.get('/', { headers: { 'Tailscale-User-Login': '' } }) - ).status(), - ).toBe(403) - expect( - ( - await request.get('/', { - headers: { 'Tailscale-User-Login': 'other@twitter-lite.invalid' }, + await request.get("/", { + headers: { "Tailscale-User-Login": "other@twitter-lite.invalid" }, }) ).status(), - ).toBe(403) -}) + ).toBe(403); +}); -test('mutations require exact Origin even with same-origin Fetch Metadata', async ({ - request, -}) => { - expect((await request.post('/')).status()).toBe(403) +test("mutations require exact Origin even with same-origin Fetch Metadata", async ({ request }) => { + expect((await request.post("/")).status()).toBe(403); expect( ( - await request.post('/', { + await request.post("/", { headers: { - Origin: 'https://other.invalid', - 'Sec-Fetch-Site': 'same-origin', + Origin: "https://other.invalid", + "Sec-Fetch-Site": "same-origin", }, }) ).status(), - ).toBe(403) -}) + ).toBe(403); +}); -test('server functions also reject foreign identity and cross-site calls', async ({ +test("server functions also reject foreign identity and cross-site calls", async ({ page, request, }) => { - const serverRequest = page.waitForRequest((request) => - request.url().includes('/_serverFn/'), - ) - await page.goto('/deck') - const url = (await serverRequest).url() + const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/")); + await page.goto("/deck"); + const url = (await serverRequest).url(); expect( ( await request.get(url, { headers: { - 'Tailscale-User-Login': 'other@twitter-lite.invalid', - 'Sec-Fetch-Site': 'same-origin', + "Tailscale-User-Login": "other@twitter-lite.invalid", + "Sec-Fetch-Site": "same-origin", }, }) ).status(), - ).toBe(403) + ).toBe(403); expect( ( await request.get(url, { - headers: { 'Sec-Fetch-Site': 'cross-site' }, + headers: { "Sec-Fetch-Site": "cross-site" }, }) ).status(), - ).toBe(403) -}) + ).toBe(403); +}); diff --git a/e2e/integrations/auth.test.ts b/e2e/integrations/auth.test.ts index 145725d..74551d8 100644 --- a/e2e/integrations/auth.test.ts +++ b/e2e/integrations/auth.test.ts @@ -1,172 +1,134 @@ -import { openDatabase } from '../../src/features/storage/database.server' -import { expect, test } from '../fixtures' +import { openDatabase } from "../../src/features/storage/database.server"; +import { expect, test } from "../fixtures"; -const origin = 'http://127.0.0.1:4173' +const origin = "http://127.0.0.1:4173"; -test('creates the owner once and enters onboarding with a real session', async ({ +test("creates the owner once and enters onboarding with a real session", async ({ page, context, a11y, }) => { - const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '') - db.$client.exec('DELETE FROM workspace_owner; DELETE FROM auth_throttle;') - db.$client.close() - await context.clearCookies() - await page.goto('/', { waitUntil: 'networkidle' }) - await expect(page).toHaveURL(/\/setup$/) - expect((await a11y().analyze()).violations).toEqual([]) - await page.getByLabel('Your name', { exact: true }).fill('Yuta') - await page.getByLabel('Email address').fill('owner@workspace.invalid') - await page - .getByLabel('Password', { exact: true }) - .fill('E2E-only-passphrase-2026') - await page - .getByLabel('Confirm password', { exact: true }) - .fill('E2E-only-passphrase-2026') - await page - .getByLabel('Setup code') - .fill('isolated-e2e-setup-token-not-for-production') - await page - .getByRole('button', { name: 'Create account', exact: true }) - .click() - await expect(page).toHaveURL(/\/onboarding$/) - await page.waitForLoadState('networkidle') - await page.getByRole('button', { name: 'Continue', exact: true }).click() - await page.getByRole('button', { name: 'Open workspace' }).click() - await expect( - page.getByRole('heading', { name: 'Good morning, Yuta' }), - ).toBeVisible() -}) + const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? ""); + db.$client.exec("DELETE FROM workspace_owner; DELETE FROM auth_throttle;"); + db.$client.close(); + await context.clearCookies(); + await page.goto("/", { waitUntil: "networkidle" }); + await expect(page).toHaveURL(/\/setup$/); + expect((await a11y().analyze()).violations).toEqual([]); + await page.getByLabel("Your name", { exact: true }).fill("Yuta"); + await page.getByLabel("Email address").fill("owner@workspace.invalid"); + await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026"); + await page.getByLabel("Confirm password", { exact: true }).fill("E2E-only-passphrase-2026"); + await page.getByLabel("Setup code").fill("isolated-e2e-setup-token-not-for-production"); + await page.getByRole("button", { name: "Create account", exact: true }).click(); + await expect(page).toHaveURL(/\/onboarding$/); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Open workspace" }).click(); + await expect(page.getByRole("heading", { name: "Good morning, Yuta" })).toBeVisible(); +}); -test('requires login for documents, server functions, and live updates', async ({ +test("requires login for documents, server functions, and live updates", async ({ page, context, }) => { - const serverRequest = page.waitForRequest((request) => - request.url().includes('/_serverFn/'), - ) - await page.goto('/deck') - const serverUrl = (await serverRequest).url() - await context.clearCookies() - for (const path of ['/api/research/events', serverUrl]) { + const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/")); + await page.goto("/deck"); + const serverUrl = (await serverRequest).url(); + await context.clearCookies(); + for (const path of ["/api/research/events", serverUrl]) { const result = await context.request.get(path, { - headers: { Origin: origin, 'Sec-Fetch-Site': 'same-origin' }, - }) - expect(result.status()).toBe(401) + headers: { Origin: origin, "Sec-Fetch-Site": "same-origin" }, + }); + expect(result.status()).toBe(401); } - await page.goto('/journal') - await expect(page).toHaveURL(/\/login$/) - await expect( - page.getByRole('heading', { name: 'Welcome back.' }), - ).toBeVisible() - await expect( - page.getByRole('link', { name: 'Journal', exact: true }), - ).toHaveCount(0) -}) + await page.goto("/journal"); + await expect(page).toHaveURL(/\/login(?:\?|$)/); + await expect(page.getByRole("heading", { name: "Welcome back." })).toBeVisible(); + await expect(page.getByRole("link", { name: "Journal", exact: true })).toHaveCount(0); +}); -test('signs in and revokes the session on sign out', async ({ - page, - context, - a11y, -}) => { - await context.clearCookies() - await page.goto('/login', { waitUntil: 'networkidle' }) - expect((await a11y().analyze()).violations).toEqual([]) - await page.getByLabel('Email address').fill('owner@workspace.invalid') - await page - .getByLabel('Password', { exact: true }) - .fill('Wrong-passphrase-2026') - await page.getByRole('button', { name: 'Sign in', exact: true }).click() - await expect(page.getByRole('alert')).toContainText( - 'Invalid email or password.', - ) - await page - .getByLabel('Password', { exact: true }) - .fill('E2E-only-passphrase-2026') - await page.getByRole('button', { name: 'Sign in', exact: true }).click() - await expect(page).toHaveURL(`${origin}/`) - await page.waitForLoadState('networkidle') - const token = (await context.cookies()).find( - (cookie) => cookie.name === 'workspace_session', - ) - expect(token?.httpOnly).toBe(true) - expect(token?.sameSite).toBe('Lax') - await page.getByRole('button', { name: 'Manage connected accounts' }).click() - await page.getByRole('button', { name: 'Sign out', exact: true }).click() - await expect(page).toHaveURL(/\/login$/) - const replay = await context.request.get('/api/auth', { +test("signs in and revokes the session on sign out", async ({ page, context, a11y }) => { + await context.clearCookies(); + await page.goto("/login", { waitUntil: "networkidle" }); + expect((await a11y().analyze()).violations).toEqual([]); + await page.getByLabel("Email address").fill("owner@workspace.invalid"); + await page.getByLabel("Password", { exact: true }).fill("Wrong-passphrase-2026"); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText("Invalid email or password."); + await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026"); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page).toHaveURL(`${origin}/`); + await page.waitForLoadState("networkidle"); + const token = (await context.cookies()).find((cookie) => cookie.name === "workspace_session"); + expect(token?.httpOnly).toBe(true); + expect(token?.sameSite).toBe("Lax"); + await page.getByRole("button", { name: "Manage connected accounts" }).click(); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await expect(page).toHaveURL(/\/login(?:\?|$)/); + const replay = await context.request.get("/api/auth", { headers: { Cookie: `workspace_session=${token?.value}` }, - }) - expect((await replay.json()).owner).toBeNull() - await page.goBack() - await expect( - page.getByRole('link', { name: 'Home', exact: true }), - ).toHaveCount(0) -}) + }); + expect((await replay.json()).owner).toBeNull(); + await page.goBack(); + await expect(page.getByRole("link", { name: "Home", exact: true })).toHaveCount(0); +}); -test('requires onboarding and remembers its completion and name', async ({ +test("requires onboarding and remembers its completion and name", async ({ page, request, a11y, }) => { - const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '') + const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? ""); db.$client - .prepare( - 'UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1', - ) - .run() + .prepare("UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1") + .run(); try { - expect((await request.get('/api/research/events')).status()).toBe(403) - await page.goto('/', { waitUntil: 'networkidle' }) - await expect(page).toHaveURL(/\/onboarding$/) - expect((await a11y().analyze()).violations).toEqual([]) - await page.getByLabel('What should we call you?').fill('Yuta Test') - await page.getByRole('button', { name: 'Continue', exact: true }).click() - await page.getByRole('button', { name: 'Open workspace' }).click() - await expect( - page.getByRole('heading', { name: 'Good morning, Yuta Test' }), - ).toBeVisible() - await page.reload() - await expect( - page.getByRole('heading', { name: 'Good morning, Yuta Test' }), - ).toBeVisible() - await page.goto('/onboarding') - await expect(page).toHaveURL(`${origin}/`) + expect((await request.get("/api/research/events")).status()).toBe(403); + await page.goto("/", { waitUntil: "networkidle" }); + await expect(page).toHaveURL(/\/onboarding$/); + expect((await a11y().analyze()).violations).toEqual([]); + await page.getByLabel("What should we call you?").fill("Yuta Test"); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Open workspace" }).click(); + await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible(); + await page.goto("/onboarding"); + await expect(page).toHaveURL(`${origin}/`); } finally { db.$client - .prepare( - 'UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1', - ) - .run('Yuta', Date.now()) - db.$client.close() + .prepare("UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1") + .run("Yuta", Date.now()); + db.$client.close(); } -}) +}); -test('rejects cross-origin login and further account registration', async ({ +test("rejects cross-origin login and further account registration", async ({ request, page, context, }) => { expect( ( - await request.post('/api/auth', { - headers: { Origin: 'https://other.invalid' }, - data: { action: 'logout' }, + await request.post("/api/auth", { + headers: { Origin: "https://other.invalid" }, + data: { action: "logout" }, }) ).status(), - ).toBe(403) - const result = await request.post('/api/auth', { + ).toBe(403); + const result = await request.post("/api/auth", { headers: { Origin: origin }, data: { - action: 'setup', - email: 'intruder@example.com', - name: 'Intruder', - password: 'Long-enough-password', - setupToken: 'arbitrary-token', + action: "setup", + email: "intruder@example.com", + name: "Intruder", + password: "Long-enough-password", + setupToken: "arbitrary-token", }, - }) - expect(result.status()).toBe(409) - await context.clearCookies() - await page.goto('/setup') - await expect(page).toHaveURL(/\/login$/) -}) + }); + expect(result.status()).toBe(409); + await context.clearCookies(); + await page.goto("/setup"); + await expect(page).toHaveURL(/\/login(?:\?|$)/); +}); diff --git a/e2e/integrations/connections.test.ts b/e2e/integrations/connections.test.ts index b37234a..73c1dd6 100644 --- a/e2e/integrations/connections.test.ts +++ b/e2e/integrations/connections.test.ts @@ -1,70 +1,52 @@ -import { expect, test } from '../fixtures' +import { expect, test } from "../fixtures"; -test('opens account management after hydration without inheriting real OAuth configuration', async ({ +test("opens account management after hydration without inheriting real OAuth configuration", async ({ page, }) => { - await page.goto('/deck') - await expect( - page.getByRole('button', { name: 'Rename', includeHidden: true }), - ).toBeEnabled() - const manage = page.getByRole('button', { name: 'Manage connected accounts' }) + await page.goto("/deck"); + await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); + const manage = page.getByRole("button", { name: "Manage connected accounts" }); if (!(await manage.isVisible())) { - await page.getByRole('button', { name: 'Toggle sidebar' }).click() + await page.getByRole("button", { name: "Toggle sidebar" }).click(); } - await expect(manage).toBeEnabled() - await manage.click() - const dialog = page.getByRole('dialog', { name: 'Connected accounts' }) - await expect(dialog).toBeVisible() - await expect( - dialog.getByText('No Mastodon servers have been configured yet.'), - ).toBeVisible() - await expect( - dialog.getByRole('button', { name: 'Authorize with Mastodon' }), - ).toHaveCount(0) - await page.keyboard.press('Escape') - await expect(dialog).toHaveCount(0) - await expect(manage).toBeFocused() -}) + await expect(manage).toBeEnabled(); + await manage.click(); + const dialog = page.getByRole("dialog", { name: "Connected accounts" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Authorize with Mastodon" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(manage).toBeFocused(); +}); -test('opens account management from the inbox footer and restores focus on close', async ({ +test("opens account management from the inbox footer and restores focus on close", async ({ page, a11y, }) => { - await page.goto('/inbox') - await page.waitForLoadState('networkidle') - const manage = page.getByRole('button', { name: 'Manage connected accounts' }) - await expect(manage).toBeVisible() - await manage.click() - const dialog = page.getByRole('dialog', { name: 'Connected accounts' }) - await expect(dialog).toBeVisible() - await expect( - dialog.getByText('No Mastodon servers have been configured yet.'), - ).toBeVisible() - expect((await a11y().analyze()).violations).toEqual([]) - await page.keyboard.press('Escape') - await expect(dialog).toHaveCount(0) - await expect(manage).toBeFocused() - await expect( - page.getByRole('textbox', { name: 'Search articles' }), - ).toBeVisible() -}) + await page.goto("/inbox"); + await page.waitForLoadState("networkidle"); + const manage = page.getByRole("button", { name: "Manage connected accounts" }); + await expect(manage).toBeVisible(); + await manage.click(); + const dialog = page.getByRole("dialog", { name: "Connected accounts" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible(); + expect((await a11y().analyze()).violations).toEqual([]); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(manage).toBeFocused(); + await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible(); +}); -test('rejects an unbound OAuth callback without contacting an instance', async ({ - page, -}) => { - await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code') - await expect(page).toHaveURL(/\/deck\?mastodon=failed$/) - await expect(page.getByRole('alert')).toContainText( - 'Could not complete Mastodon authorization.', - ) - await expect( - page.getByRole('button', { name: 'Rename', includeHidden: true }), - ).toBeEnabled() - const manage = page.getByRole('button', { name: 'Manage connected accounts' }) +test("rejects an unbound OAuth callback without contacting an instance", async ({ page }) => { + await page.goto("/oauth/mastodon/callback?state=invalid&code=not-a-real-code"); + await expect(page).toHaveURL(/\/deck\?mastodon=failed$/); + await expect(page.getByRole("alert")).toContainText("Could not complete Mastodon authorization."); + await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); + const manage = page.getByRole("button", { name: "Manage connected accounts" }); if (!(await manage.isVisible())) { - await page.getByRole('button', { name: 'Toggle sidebar' }).click() + await page.getByRole("button", { name: "Toggle sidebar" }).click(); } - await expect( - page.getByRole('button', { name: 'Manage connected accounts' }), - ).toBeEnabled() -}) + await expect(page.getByRole("button", { name: "Manage connected accounts" })).toBeEnabled(); +}); diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts index e917e73..0695d64 100644 --- a/e2e/integrations/deck.test.ts +++ b/e2e/integrations/deck.test.ts @@ -1,296 +1,230 @@ -import type { Page } from '@playwright/test' -import { expect, test } from '../fixtures' -import { closeMobileSidebar, showDeckManagement } from '../workspace' +import type { Page } from "@playwright/test"; +import { expect, test } from "../fixtures"; +import { closeMobileSidebar, showDeckManagement } from "../workspace"; async function addColumn( page: Page, title: string, query: string, - profile = 'e2e', - kind = 'search', + profile = "e2e", + kind = "search", ) { - await showDeckManagement(page) - await page.getByRole('button', { name: 'Add column', exact: true }).click() - const editor = page.getByRole('form', { name: 'Add column', exact: true }) - await editor.getByLabel('Column name', { exact: true }).fill(title) + await showDeckManagement(page); + await page.getByRole("button", { name: "Add column", exact: true }).click(); + const editor = page.getByRole("form", { name: "Add column", exact: true }); + await editor.getByLabel("Column name", { exact: true }).fill(title); await editor - .getByRole('combobox', { name: 'Connection profile', exact: true }) - .selectOption({ label: profile }) + .getByRole("combobox", { name: "Connection profile", exact: true }) + .selectOption({ label: profile }); + await editor.getByRole("combobox", { name: "Column type", exact: true }).selectOption(kind); await editor - .getByRole('combobox', { name: 'Column type', exact: true }) - .selectOption(kind) - await editor - .getByLabel( - kind === 'search' - ? 'Twitter search query' - : kind === 'user' - ? 'User' - : 'List', - { exact: true }, - ) - .fill(query) - await editor.getByRole('button', { name: 'Add and search' }).click() - await closeMobileSidebar(page) + .getByLabel(kind === "search" ? "Twitter search query" : kind === "user" ? "User" : "List", { + exact: true, + }) + .fill(query); + await editor.getByRole("button", { name: "Add and search" }).click(); + await closeMobileSidebar(page); await expect( - page - .getByRole('region', { name: title, exact: true }) - .locator('article') - .first(), - ).toBeVisible() + page.getByRole("region", { name: title, exact: true }).locator("article").first(), + ).toBeVisible(); } test.beforeEach(async ({ page }) => { - await page.goto('/deck') - await expect( - page.getByRole('button', { name: 'Save as deck', exact: true }), - ).toBeEnabled() - await page.getByRole('button', { name: 'Save as deck', exact: true }).click() - await showDeckManagement(page) - await expect( - page.getByText('Saved to server · Shared across devices'), - ).toBeVisible() - await closeMobileSidebar(page) -}) + await page.goto("/deck"); + await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "Save as deck", exact: true }).click(); + await showDeckManagement(page); + await expect(page.getByText("Saved to server · Shared across devices")).toBeVisible(); + await closeMobileSidebar(page); +}); -test('binds identical searches to independent profiles through paging, editing and reload', async ({ +test("binds identical searches to independent profiles through paging, editing and reload", async ({ page, }) => { - await addColumn(page, '通常の接続', 'same-query') - await addColumn(page, '別の接続', 'same-query', 'alternate') - const first = page.getByRole('region', { name: '通常の接続', exact: true }) - const second = page.getByRole('region', { name: '別の接続', exact: true }) - await expect( - first.getByText('Latest · all page 1', { exact: true }), - ).toBeVisible() - await expect( - second.getByText('alternate · Latest · all page 1', { exact: true }), - ).toBeVisible() - await first.getByRole('button', { name: 'Load more' }).click() - await expect(first.locator('article')).toHaveCount(2) - await expect(second.locator('article')).toHaveCount(1) - await second.getByRole('button', { name: 'Load more' }).click() - await expect( - second.getByText('alternate · Latest · all page 2'), - ).toBeVisible() - await second.getByRole('button', { name: 'Actions for 別の接続' }).click() - await page.getByRole('menuitem', { name: 'Edit', exact: true }).click() + await addColumn(page, "通常の接続", "same-query"); + await addColumn(page, "別の接続", "same-query", "alternate"); + const first = page.getByRole("region", { name: "通常の接続", exact: true }); + const second = page.getByRole("region", { name: "別の接続", exact: true }); + await expect(first.getByText("Latest · all page 1", { exact: true })).toBeVisible(); + await expect(second.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible(); + await first.getByRole("button", { name: "Load more" }).click(); + await expect(first.locator("article")).toHaveCount(2); + await expect(second.locator("article")).toHaveCount(1); + await second.getByRole("button", { name: "Load more" }).click(); + await expect(second.getByText("alternate · Latest · all page 2")).toBeVisible(); + await second.getByRole("button", { name: "Actions for 別の接続" }).click(); + await page.getByRole("menuitem", { name: "Edit", exact: true }).click(); await page - .getByRole('combobox', { name: 'Connection profile', exact: true }) - .selectOption({ label: 'e2e' }) - await page.getByRole('button', { name: 'Save changes' }).click() - await expect( - second.getByText('Latest · all page 1', { exact: true }), - ).toBeVisible() - await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount( - 0, - ) - await page.reload() - await expect( - second.getByText('Latest · all page 1', { exact: true }), - ).toBeVisible() - await expect(first.locator('article')).toHaveCount(1) -}) + .getByRole("combobox", { name: "Connection profile", exact: true }) + .selectOption({ label: "e2e" }); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible(); + await expect(second.getByText("alternate · Latest · all page 1")).toHaveCount(0); + await page.reload(); + await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible(); + await expect(first.locator("article")).toHaveCount(1); +}); -test('creates, switches, renames and deletes persisted deck profiles', async ({ +test("creates, switches, renames and deletes persisted deck profiles", async ({ page }) => { + await page.getByRole("button", { name: "Rename" }).click(); + await page.getByLabel("Research topic / deck name").fill("最初の調査"); + await page.getByRole("button", { name: "Save name" }).click(); + await addColumn(page, "最初の観点", "first-deck"); + await showDeckManagement(page); + await page.getByRole("button", { name: "Create deck", exact: true }).click(); + await page.getByLabel("New deck name").fill("別の調査"); + await page.getByRole("button", { name: "Create", exact: true }).click(); + await expect(page.locator(".deck-column")).toHaveCount(0); + await addColumn(page, "別の観点", "second-deck", "alternate"); + await showDeckManagement(page); + await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" }); + await closeMobileSidebar(page); + await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]); + await page.reload(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査"); + await showDeckManagement(page); + await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" }); + await closeMobileSidebar(page); + await expect(page.locator(".deck-column h2")).toHaveText(["別の観点"]); + await showDeckManagement(page); + await page.getByRole("button", { name: "Delete deck", exact: true }).click(); + await page.getByRole("button", { name: "Delete", exact: true }).click(); + await page.reload(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査"); + await showDeckManagement(page); + await expect(page.getByLabel("Deck profile", { exact: true }).locator("option")).toHaveCount(1); +}); + +test("reads user and list timelines within columns and reorders, deletes and restores them", async ({ page, }) => { - await page.getByRole('button', { name: 'Rename' }).click() - await page.getByLabel('Research topic / deck name').fill('最初の調査') - await page.getByRole('button', { name: 'Save name' }).click() - await addColumn(page, '最初の観点', 'first-deck') - await showDeckManagement(page) - await page.getByRole('button', { name: 'Create deck', exact: true }).click() - await page.getByLabel('New deck name').fill('別の調査') - await page.getByRole('button', { name: 'Create', exact: true }).click() - await expect(page.locator('.deck-column')).toHaveCount(0) - await addColumn(page, '別の観点', 'second-deck', 'alternate') - await showDeckManagement(page) - await page - .getByLabel('Deck profile', { exact: true }) - .selectOption({ label: '最初の調査' }) - await closeMobileSidebar(page) - await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) - await page.reload() - await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') - await showDeckManagement(page) - await page - .getByLabel('Deck profile', { exact: true }) - .selectOption({ label: '別の調査' }) - await closeMobileSidebar(page) - await expect(page.locator('.deck-column h2')).toHaveText(['別の観点']) - await showDeckManagement(page) - await page.getByRole('button', { name: 'Delete deck', exact: true }).click() - await page.getByRole('button', { name: 'Delete', exact: true }).click() - await page.reload() - await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') - await showDeckManagement(page) - await expect( - page.getByLabel('Deck profile', { exact: true }).locator('option'), - ).toHaveCount(1) -}) + await addColumn(page, "アカウント", "yuta", "alternate", "user"); + await addColumn(page, "リストの投稿", "1234567890", "e2e", "list"); + const user = page.getByRole("region", { name: "アカウント", exact: true }); + const list = page.getByRole("region", { name: "リストの投稿", exact: true }); + await expect(user.getByText("alternate profile page 1")).toBeVisible(); + await expect(list.getByText("list page 1")).toBeVisible(); + await list.getByRole("button", { name: "Load more" }).click(); + await expect(list.getByText("list page 2")).toBeVisible(); + await list.getByRole("button", { name: "Actions for リストの投稿" }).click(); + await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click(); + await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]); + await list.getByRole("button", { name: "Actions for リストの投稿" }).click(); + await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click(); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]); + await page.reload(); + await expect(user.getByText("alternate profile page 1")).toBeVisible(); + await expect(list.getByText("list page 1")).toBeVisible(); +}); -test('reads user and list timelines within columns and reorders, deletes and restores them', async ({ - page, -}) => { - await addColumn(page, 'アカウント', 'yuta', 'alternate', 'user') - await addColumn(page, 'リストの投稿', '1234567890', 'e2e', 'list') - const user = page.getByRole('region', { name: 'アカウント', exact: true }) - const list = page.getByRole('region', { name: 'リストの投稿', exact: true }) - await expect(user.getByText('alternate profile page 1')).toBeVisible() - await expect(list.getByText('list page 1')).toBeVisible() - await list.getByRole('button', { name: 'Load more' }).click() - await expect(list.getByText('list page 2')).toBeVisible() - await list.getByRole('button', { name: 'Actions for リストの投稿' }).click() - await page.getByRole('menuitem', { name: 'Move リストの投稿 left' }).click() - await expect(page.locator('.deck-column h2')).toHaveText([ - 'リストの投稿', - 'アカウント', - ]) - await list.getByRole('button', { name: 'Actions for リストの投稿' }).click() - await page.getByRole('menuitem', { name: 'Delete リストの投稿' }).click() - await page.getByRole('button', { name: 'Undo' }).click() - await expect(page.locator('.deck-column h2')).toHaveText([ - 'リストの投稿', - 'アカウント', - ]) - await page.reload() - await expect(user.getByText('alternate profile page 1')).toBeVisible() - await expect(list.getByText('list page 1')).toBeVisible() -}) - -test('keeps other columns usable when pagination fails and retries that column', async ({ +test("keeps other columns usable when pagination fails and retries that column", async ({ page, }, testInfo) => { - await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`) - await addColumn(page, '別の検索', 'independent-deck') - const failed = page.getByRole('region', { name: '失敗する検索', exact: true }) - const other = page.getByRole('region', { name: '別の検索', exact: true }) - await failed.getByRole('button', { name: 'Load more' }).click() - await expect(failed.getByRole('alert')).toBeVisible() - await expect(failed.locator('article')).toHaveCount(1) - await other.getByRole('button', { name: 'Load more' }).click() - await expect(other.locator('article')).toHaveCount(2) - await failed.getByRole('button', { name: 'Retry' }).click() - await expect(failed.locator('article')).toHaveCount(2) -}) + await addColumn(page, "失敗する検索", `retry-deck-${testInfo.project.name}`); + await addColumn(page, "別の検索", "independent-deck"); + const failed = page.getByRole("region", { name: "失敗する検索", exact: true }); + const other = page.getByRole("region", { name: "別の検索", exact: true }); + await failed.getByRole("button", { name: "Load more" }).click(); + await expect(failed.getByRole("alert")).toBeVisible(); + await expect(failed.locator("article")).toHaveCount(1); + await other.getByRole("button", { name: "Load more" }).click(); + await expect(other.locator("article")).toHaveCount(2); + await failed.getByRole("button", { name: "Retry" }).click(); + await expect(failed.locator("article")).toHaveCount(2); +}); -test('keeps the deck and editor accessible within narrow viewports', async ({ +test("keeps the deck and editor accessible within narrow viewports", async ({ page, a11y, }, testInfo) => { - await addColumn(page, '日本語の反応', 'WebMCP lang:ja') - await addColumn(page, '海外の反応', 'WebMCP lang:en', 'alternate') - await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user') + await addColumn(page, "日本語の反応", "WebMCP lang:ja"); + await addColumn(page, "海外の反応", "WebMCP lang:en", "alternate"); + await addColumn(page, "公式アカウント", "OpenAI", "e2e", "user"); for (const width of [320, 375, 414, 768]) { - await page.setViewportSize({ width, height: 900 }) - expect( - await page.evaluate( - () => document.documentElement.scrollWidth > innerWidth, - ), - ).toBe(false) - expect( - await page.evaluate( - () => document.documentElement.scrollHeight <= innerHeight, - ), - ).toBe(true) - const board = await page.locator('.deck-board').boundingBox() - expect(board).not.toBeNull() - expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901) + await page.setViewportSize({ width, height: 900 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)).toBe( + false, + ); + expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe( + true, + ); + const board = await page.locator(".deck-board").boundingBox(); + expect(board).not.toBeNull(); + expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901); } await page.setViewportSize({ - width: testInfo.project.name === 'desktop' ? 1440 : 393, + width: testInfo.project.name === "desktop" ? 1440 : 393, height: 900, - }) - await expect(page.getByRole('navigation', { name: 'Columns' })).toHaveCount(0) - await expect( - page.getByRole('heading', { name: '公式アカウント', exact: true }), - ).toBeVisible() - expect((await a11y().analyze()).violations).toEqual([]) + }); + await expect(page.getByRole("navigation", { name: "Columns" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "公式アカウント", exact: true })).toBeVisible(); + expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ - path: testInfo.outputPath('deck-populated.png'), + path: testInfo.outputPath("deck-populated.png"), fullPage: true, - }) - await showDeckManagement(page) - await page.getByRole('button', { name: 'Add column', exact: true }).click() - expect((await a11y().analyze()).violations).toEqual([]) + }); + await showDeckManagement(page); + await page.getByRole("button", { name: "Add column", exact: true }).click(); + expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ - path: testInfo.outputPath('deck.png'), + path: testInfo.outputPath("deck.png"), fullPage: true, - }) -}) + }); +}); -test('closes the column editor with Escape and restores focus without applying edits', async ({ +test("closes the column editor with Escape and restores focus without applying edits", async ({ page, }) => { - await showDeckManagement(page) - const opener = page.getByRole('button', { name: 'Add column', exact: true }) - await opener.click() - await expect( - page.getByRole('dialog', { name: 'Add column', exact: true }), - ).toBeVisible() - await page - .getByLabel('Column name', { exact: true }) - .fill('キャンセルする観点') - await page.keyboard.press('Escape') - await expect( - page.getByRole('dialog', { name: 'Add column', exact: true }), - ).toHaveCount(0) - await expect(opener).toBeFocused() - await expect(page.locator('.deck-column')).toHaveCount(0) - await opener.click() - await expect(page.getByLabel('Column name', { exact: true })).toHaveValue('') -}) + await showDeckManagement(page); + const opener = page.getByRole("button", { name: "Add column", exact: true }); + await opener.click(); + await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toBeVisible(); + await page.getByLabel("Column name", { exact: true }).fill("キャンセルする観点"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0); + await expect(opener).toBeFocused(); + await expect(page.locator(".deck-column")).toHaveCount(0); + await opener.click(); + await expect(page.getByLabel("Column name", { exact: true })).toHaveValue(""); +}); -test('does not overwrite unreadable storage on reload', async ({ page }) => { - await page.evaluate(() => - localStorage.setItem('twitter-lite-research-deck', '{broken'), - ) - await page.reload() - await expect(page.getByRole('alert')).toContainText('Unable to load') - expect( - await page.evaluate(() => - localStorage.getItem('twitter-lite-research-deck'), - ), - ).toBe('{broken') -}) +test("does not overwrite unreadable storage on reload", async ({ page }) => { + await page.evaluate(() => localStorage.setItem("twitter-lite-research-deck", "{broken")); + await page.reload(); + await expect(page.getByRole("alert")).toContainText("Unable to load"); + expect(await page.evaluate(() => localStorage.getItem("twitter-lite-research-deck"))).toBe( + "{broken", + ); +}); -test('selects a list from the column profile catalog and refreshes it on profile change', async ({ +test("selects a list from the column profile catalog and refreshes it on profile change", async ({ page, }) => { - await showDeckManagement(page) - await page.getByRole('button', { name: 'Add column', exact: true }).click() - await page.getByLabel('Column name', { exact: true }).fill('購読リスト') + await showDeckManagement(page); + await page.getByRole("button", { name: "Add column", exact: true }).click(); + await page.getByLabel("Column name", { exact: true }).fill("購読リスト"); await page - .getByRole('combobox', { name: 'Connection profile', exact: true }) - .selectOption({ label: 'e2e' }) + .getByRole("combobox", { name: "Connection profile", exact: true }) + .selectOption({ label: "e2e" }); + await page.getByRole("combobox", { name: "Column type", exact: true }).selectOption("list"); + await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toBeVisible(); await page - .getByRole('combobox', { name: 'Column type', exact: true }) - .selectOption('list') - await expect( - page.getByRole('button', { name: 'My E2E List', exact: true }), - ).toBeVisible() - await page - .getByRole('combobox', { name: 'Connection profile', exact: true }) - .selectOption({ label: 'alternate' }) - await expect( - page.getByRole('button', { name: 'My E2E List', exact: true }), - ).toHaveCount(0) - await page - .getByRole('button', { name: 'Alternate List', exact: true }) - .click() - await expect(page.getByLabel('List', { exact: true })).toHaveValue( - '1234567890', - ) - await page.getByRole('button', { name: 'Add and search' }).click() - await closeMobileSidebar(page) + .getByRole("combobox", { name: "Connection profile", exact: true }) + .selectOption({ label: "alternate" }); + await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Alternate List", exact: true }).click(); + await expect(page.getByLabel("List", { exact: true })).toHaveValue("1234567890"); + await page.getByRole("button", { name: "Add and search" }).click(); + await closeMobileSidebar(page); await expect( page - .getByRole('region', { name: '購読リスト', exact: true }) - .getByText('list page 1', { exact: true }), - ).toBeVisible() -}) + .getByRole("region", { name: "購読リスト", exact: true }) + .getByText("list page 1", { exact: true }), + ).toBeVisible(); +}); -test('keeps an open draft through remote edits and rejects its stale save', async ({ +test("keeps an open draft through remote edits and rejects its stale save", async ({ page, browser, baseURL, @@ -298,124 +232,96 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn const other = await browser.newContext({ storageState: await page.context().storageState(), baseURL, - extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, - }) + extraHTTPHeaders: { "Tailscale-User-Login": "owner@twitter-lite.invalid" }, + }); try { - const device = await other.newPage() - await device.goto('/deck') - await page.getByRole('button', { name: 'Rename' }).click() - await page.getByLabel('Research topic / deck name').fill('編集中の名前') - await device.getByRole('button', { name: 'Rename' }).click() - await device.getByLabel('Research topic / deck name').fill('別の端末の変更') - await device.getByRole('button', { name: 'Save name' }).click() - await expect(device.getByRole('heading', { level: 1 })).toHaveText( - '別の端末の変更', - ) - await page.evaluate(() => window.dispatchEvent(new Event('focus'))) - await expect(page.getByLabel('Research topic / deck name')).toHaveValue( - '編集中の名前', - ) - await page.getByRole('button', { name: 'Save name' }).click() - await expect(page.getByRole('alert')).toContainText( - 'This deck changed on another device', - ) - await expect(page.getByLabel('Research topic / deck name')).toHaveValue( - '編集中の名前', - ) - await page.keyboard.press('Escape') - await page.getByRole('button', { name: 'Reload latest deck' }).click() - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - '別の端末の変更', - ) + const device = await other.newPage(); + await device.goto("/deck"); + await page.getByRole("button", { name: "Rename" }).click(); + await page.getByLabel("Research topic / deck name").fill("編集中の名前"); + await device.getByRole("button", { name: "Rename" }).click(); + await device.getByLabel("Research topic / deck name").fill("別の端末の変更"); + await device.getByRole("button", { name: "Save name" }).click(); + await expect(device.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更"); + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前"); + await page.getByRole("button", { name: "Save name" }).click(); + await expect(page.getByRole("alert")).toContainText("This deck changed on another device"); + await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前"); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Reload latest deck" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更"); } finally { - await other.close() + await other.close(); } -}) +}); -test('imports old browser decks explicitly and avoids duplicate imports after retry', async ({ +test("imports old browser decks explicitly and avoids duplicate imports after retry", async ({ page, }) => { const legacy = { version: 2, - activeDeckId: 'old', + activeDeckId: "old", decks: [ { - id: 'old', - title: '以前の調査', + id: "old", + title: "以前の調査", columns: [ { - id: 'a', - title: '以前の観点', - profileName: 'e2e', + id: "a", + title: "以前の観点", + profileName: "e2e", source: { - platform: 'twitter', - kind: 'search', - query: 'WebMCP', - product: 'Latest', + platform: "twitter", + kind: "search", + query: "WebMCP", + product: "Latest", following: false, }, }, ], }, ], - } + }; await page.evaluate( - (legacy) => - localStorage.setItem( - 'twitter-lite-research-deck', - JSON.stringify(legacy), - ), + (legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)), legacy, - ) - await page.reload() - await showDeckManagement(page) - const selector = page.getByLabel('Deck profile', { exact: true }) - await expect(selector.locator('option')).toHaveCount(1) - await closeMobileSidebar(page) - await page.getByRole('button', { name: 'Import legacy decks' }).click() - await showDeckManagement(page) - await expect(selector.locator('option')).toHaveCount(2) - await selector.selectOption({ label: '以前の調査' }) - await closeMobileSidebar(page) - await expect( - page.getByText('Latest · all page 1', { exact: true }), - ).toBeVisible() + ); + await page.reload(); + await showDeckManagement(page); + const selector = page.getByLabel("Deck profile", { exact: true }); + await expect(selector.locator("option")).toHaveCount(1); + await closeMobileSidebar(page); + await page.getByRole("button", { name: "Import legacy decks" }).click(); + await showDeckManagement(page); + await expect(selector.locator("option")).toHaveCount(2); + await selector.selectOption({ label: "以前の調査" }); + await closeMobileSidebar(page); + await expect(page.getByText("Latest · all page 1", { exact: true })).toBeVisible(); await page.evaluate( - (legacy) => - localStorage.setItem( - 'twitter-lite-research-deck', - JSON.stringify(legacy), - ), + (legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)), legacy, - ) - await page.reload() - await page.getByRole('button', { name: 'Import legacy decks' }).click() - await expect( - page.getByRole('button', { name: 'Import legacy decks' }), - ).toHaveCount(0) - await showDeckManagement(page) - await expect(selector.locator('option')).toHaveCount(2) -}) + ); + await page.reload(); + await page.getByRole("button", { name: "Import legacy decks" }).click(); + await expect(page.getByRole("button", { name: "Import legacy decks" })).toHaveCount(0); + await showDeckManagement(page); + await expect(selector.locator("option")).toHaveCount(2); +}); -test('preserves a new deck dialog and its draft across the mobile breakpoint', async ({ - page, -}) => { - await showDeckManagement(page) - await page.getByRole('button', { name: 'Create deck', exact: true }).click() - const dialog = page.getByRole('dialog', { name: 'Create deck', exact: true }) - await dialog.getByLabel('New deck name').fill('画面を回転しても残る調査') +test("preserves a new deck dialog and its draft across the mobile breakpoint", async ({ page }) => { + await showDeckManagement(page); + await page.getByRole("button", { name: "Create deck", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Create deck", exact: true }); + await dialog.getByLabel("New deck name").fill("画面を回転しても残る調査"); for (const width of [1024, 375, 1024]) { - await page.setViewportSize({ width, height: 900 }) - await expect(dialog).toHaveCount(1) - await expect(dialog).toBeVisible() - await expect(dialog.getByLabel('New deck name')).toHaveValue( - '画面を回転しても残る調査', - ) - await expect(page.locator('#new-deck-title')).toHaveCount(1) + await page.setViewportSize({ width, height: 900 }); + await expect(dialog).toHaveCount(1); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("New deck name")).toHaveValue("画面を回転しても残る調査"); + await expect(page.locator("#new-deck-title")).toHaveCount(1); } - await dialog.getByRole('button', { name: 'Create', exact: true }).click() - await expect(dialog).toHaveCount(0) - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - '画面を回転しても残る調査', - ) -}) + await dialog.getByRole("button", { name: "Create", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("画面を回転しても残る調査"); +}); diff --git a/e2e/integrations/home.test.ts b/e2e/integrations/home.test.ts index 610abaf..51b3803 100644 --- a/e2e/integrations/home.test.ts +++ b/e2e/integrations/home.test.ts @@ -1,139 +1,113 @@ -import { expect, test } from '../fixtures' +import { expect, test } from "../fixtures"; -test('previews a natural-language memo and shares the executed record with journal', async ({ +test("previews a natural-language memo and shares the executed record with journal", async ({ page, }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - const memo = '明日のレビューは導線から確認する' - await page.getByRole('textbox', { name: 'Home input' }).fill(`Note: ${memo}`) - await page.getByRole('button', { name: 'Preview action' }).click() - const execute = page.getByRole('button', { name: 'Apply', exact: true }) - await expect(execute).toBeVisible() - await execute.click() - await page.getByRole('link', { name: 'Journal', exact: true }).first().click() - await expect(page.getByRole('article', { name: 'Note entry' })).toContainText( - memo, - ) - await page.getByRole('link', { name: 'Home', exact: true }).first().click() - await expect(page.getByRole('textbox', { name: 'Home input' })).toBeVisible() -}) + await page.goto("/"); + await page.waitForLoadState("networkidle"); + const memo = "明日のレビューは導線から確認する"; + await page.getByRole("textbox", { name: "Home input" }).fill(`Note: ${memo}`); + await page.getByRole("button", { name: "Preview action" }).click(); + const execute = page.getByRole("button", { name: "Apply", exact: true }); + await expect(execute).toBeVisible(); + await execute.click(); + await page.getByRole("link", { name: "Journal", exact: true }).first().click(); + await expect(page.getByRole("article", { name: "Note entry" })).toContainText(memo); + await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible(); +}); -test('keeps the home usable on narrow screens', async ({ - page, - a11y, -}, testInfo) => { - await page.goto('/') - await page.waitForLoadState('networkidle') +test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); for (const width of [320, 375, 768]) { - await page.setViewportSize({ width, height: 850 }) - await expect( - page.getByRole('textbox', { name: 'Home input' }), - ).toBeVisible() - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) - await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) }) - if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) + await page.setViewportSize({ width, height: 850 }); + await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) }); + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); } -}) +}); -test('adds tasks only after confirmation and can defer and restore them', async ({ - page, -}) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - const input = page.getByRole('textbox', { name: 'Home input' }) - const complete = page.getByRole('button', { - name: 'Complete grocery shopping', +test("adds tasks only after confirmation and can defer and restore them", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + const input = page.getByRole("textbox", { name: "Home input" }); + const complete = page.getByRole("button", { + name: "Complete grocery shopping", exact: true, - }) - await input.fill('Add grocery shopping') - await page.getByRole('button', { name: 'Preview action' }).click() - await expect(complete).toHaveCount(0) - await page.getByRole('button', { name: 'Apply', exact: true }).click() - await expect(complete).toBeVisible() - await input.fill('Defer grocery shopping') - await page.getByRole('button', { name: 'Preview action' }).click() - await expect(complete).toBeVisible() - await page.getByRole('button', { name: 'Apply', exact: true }).click() - await expect(complete).toHaveCount(0) - await page.locator('.home-rest summary').click() - await page - .getByRole('button', { name: 'Restore grocery shopping', exact: true }) - .click() - await complete.click() - await expect(complete).toHaveCount(0) - await page.locator('.home-rest summary').click() + }); + await input.fill("Add grocery shopping"); + await page.getByRole("button", { name: "Preview action" }).click(); + await expect(complete).toHaveCount(0); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(complete).toBeVisible(); + await input.fill("Defer grocery shopping"); + await page.getByRole("button", { name: "Preview action" }).click(); + await expect(complete).toBeVisible(); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(complete).toHaveCount(0); + await page.locator(".home-rest summary").click(); + await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click(); + await complete.click(); + await expect(complete).toHaveCount(0); + await page.locator(".home-rest summary").click(); await expect( - page.getByRole('button', { name: 'Restore grocery shopping', exact: true }), - ).toBeVisible() -}) + page.getByRole("button", { name: "Restore grocery shopping", exact: true }), + ).toBeVisible(); +}); -test('resolves communication within home and carries the status to the shared queue', async ({ +test("resolves communication within home and carries the status to the shared queue", async ({ page, a11y, }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - await page - .getByRole('button', { name: 'Open conversation with Haruka Aoki' }) - .click() - const dialog = page.getByRole('dialog', { - name: 'Conversation with Haruka Aoki', - }) + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click(); + const dialog = page.getByRole("dialog", { + name: "Conversation with Haruka Aoki", + }); + await expect(dialog.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + expect((await a11y().analyze()).violations).toEqual([]); + await dialog.getByRole("button", { name: "Leave for later" }).click(); + await expect(dialog).toHaveCount(0); await expect( - dialog.getByRole('textbox', { name: 'Reply', exact: true }), - ).toBeVisible() - expect((await a11y().analyze()).violations).toEqual([]) - await dialog.getByRole('button', { name: 'Leave for later' }).click() - await expect(dialog).toHaveCount(0) + page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), + ).toHaveCount(0); + await page.getByRole("link", { name: "Messages", exact: true }).first().click(); + const rows = page.locator(".support-row"); + await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await rows.filter({ hasText: "Schedule next week’s design review" }).click(); + await page.getByRole("button", { name: "Move to Now" }).click(); + const back = page.getByRole("button", { name: "Back to messages" }); + if (await back.isVisible()) await back.click(); + await page.getByRole("link", { name: "Home", exact: true }).first().click(); await expect( - page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }), - ).toHaveCount(0) - await page - .getByRole('link', { name: 'Messages', exact: true }) - .first() - .click() - const rows = page.locator('.support-row') - await expect( - rows.filter({ hasText: 'Schedule next week’s design review' }), - ).toHaveCount(0) - await page.getByRole('button', { name: 'Later', exact: true }).click() - await rows.filter({ hasText: 'Schedule next week’s design review' }).click() - await page.getByRole('button', { name: 'Move to Now' }).click() - const back = page.getByRole('button', { name: 'Back to messages' }) - if (await back.isVisible()) await back.click() - await page.getByRole('link', { name: 'Home', exact: true }).first().click() - await expect( - page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }), - ).toBeVisible() -}) + page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), + ).toBeVisible(); +}); -test('updates the prose brief as priorities change and opens the referenced conversation', async ({ +test("updates the prose brief as priorities change and opens the referenced conversation", async ({ page, }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - const brief = page.getByRole('region', { name: 'Daily brief' }) - await expect(brief).toContainText('Review the documents') - await expect(brief).toContainText('4 conversations') - await page - .getByRole('button', { name: 'Complete Review the documents', exact: true }) - .click() - await expect(brief).not.toContainText('Review the documents') - await brief.getByRole('button', { name: 'Reply to Haruka Aoki' }).click() - const dialog = page.getByRole('dialog', { - name: 'Conversation with Haruka Aoki', + await page.goto("/"); + await page.waitForLoadState("networkidle"); + const brief = page.getByRole("region", { name: "Daily brief" }); + await expect(brief).toContainText("Review the documents"); + await expect(brief).toContainText("4 conversations"); + await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); + await expect(brief).not.toContainText("Review the documents"); + await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click(); + const dialog = page.getByRole("dialog", { + name: "Conversation with Haruka Aoki", exact: true, - }) - await dialog.getByRole('button', { name: 'Leave for later' }).click() - await expect(brief).toContainText('3 conversations') - await expect(brief).not.toContainText('Haruka Aoki') - await page - .getByRole('textbox', { name: 'Home input' }) - .fill('再開地点:資料の2ページ目から') - await page.getByRole('button', { name: 'Preview action' }).click() - await page.getByRole('button', { name: 'Apply', exact: true }).click() - await expect(brief).toContainText('資料の2ページ目から') -}) + }); + await dialog.getByRole("button", { name: "Leave for later" }).click(); + await expect(brief).toContainText("3 conversations"); + await expect(brief).not.toContainText("Haruka Aoki"); + await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から"); + await page.getByRole("button", { name: "Preview action" }).click(); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(brief).toContainText("資料の2ページ目から"); +}); diff --git a/e2e/integrations/inbox.test.ts b/e2e/integrations/inbox.test.ts index 1444130..558f67f 100644 --- a/e2e/integrations/inbox.test.ts +++ b/e2e/integrations/inbox.test.ts @@ -1,101 +1,75 @@ -import { expect, test } from '../fixtures' +import { expect, test } from "../fixtures"; -test('selects articles and keeps the reader focused on their content', async ({ - page, -}) => { - await page.goto('/inbox') - await page.waitForLoadState('networkidle') - const rows = page.locator('.inbox-article-row') - await expect(rows).toHaveCount(9) - const articleTitle = 'Context for an agent can be brief' - await rows.filter({ hasText: articleTitle }).click() - const reader = page.getByRole('article', { name: articleTitle, exact: true }) - await expect(reader).toBeVisible() +test("selects articles and keeps the reader focused on their content", async ({ page }) => { + await page.goto("/inbox"); + await page.waitForLoadState("networkidle"); + const rows = page.locator(".inbox-article-row"); + await expect(rows).toHaveCount(9); + const articleTitle = "Context for an agent can be brief"; + await rows.filter({ hasText: articleTitle }).click(); + const reader = page.getByRole("article", { name: articleTitle, exact: true }); + await expect(reader).toBeVisible(); + await expect(reader.getByRole("heading", { name: articleTitle, exact: true })).toBeVisible(); await expect( - reader.getByRole('heading', { name: articleTitle, exact: true }), - ).toBeVisible() + reader.getByText("Start with a one-sentence goal, then add the constraints that must hold.", { + exact: false, + }), + ).toBeVisible(); + await expect(page.getByText("FOR YOU", { exact: true })).toHaveCount(0); + await expect(page.getByText("Mock", { exact: true })).toHaveCount(0); + await expect(page.getByText("Why this article", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: /For you|Read later|Unread only/ })).toHaveCount(0); + const back = page.getByRole("button", { name: "Back to articles" }); + if (await back.isVisible()) await back.click(); + await rows.filter({ hasText: "Three columns create a rhythm for reading" }).click(); await expect( - reader.getByText( - 'Start with a one-sentence goal, then add the constraints that must hold.', - { exact: false }, - ), - ).toBeVisible() - await expect(page.getByText('FOR YOU', { exact: true })).toHaveCount(0) - await expect(page.getByText('Mock', { exact: true })).toHaveCount(0) - await expect(page.getByText('Why this article', { exact: true })).toHaveCount( - 0, - ) - await expect( - page.getByRole('button', { name: /For you|Read later|Unread only/ }), - ).toHaveCount(0) - const back = page.getByRole('button', { name: 'Back to articles' }) - if (await back.isVisible()) await back.click() - await rows - .filter({ hasText: 'Three columns create a rhythm for reading' }) - .click() - await expect( - page.getByRole('article', { - name: 'Three columns create a rhythm for reading', + page.getByRole("article", { + name: "Three columns create a rhythm for reading", exact: true, }), - ).toBeVisible() -}) + ).toBeVisible(); +}); -test('recovers from empty search and shares navigation with Research', async ({ - page, - a11y, -}) => { - await page.goto('/inbox') - await page.waitForLoadState('networkidle') - const rows = page.locator('.inbox-article-row') - await page - .getByRole('textbox', { name: 'Search articles' }) - .fill('nonexistent-query-xyz') - await expect(rows).toHaveCount(0) - await expect( - page.getByText('No articles found', { exact: true }), - ).toBeVisible() - await page.getByRole('button', { name: 'Clear search' }).click() - await expect(rows).toHaveCount(9) - expect((await a11y().analyze()).violations).toEqual([]) - await expect( - page.getByRole('button', { name: 'Edit interests', exact: true }), - ).toHaveCount(0) - await page.getByRole('link', { name: 'Research', exact: true }).click() - await expect(page).toHaveURL(/\/deck$/) - await page.getByRole('link', { name: 'Reader', exact: true }).click() - await expect(page).toHaveURL(/\/inbox$/) - await expect(rows).toHaveCount(9) -}) +test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => { + await page.goto("/inbox"); + await page.waitForLoadState("networkidle"); + const rows = page.locator(".inbox-article-row"); + await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz"); + await expect(rows).toHaveCount(0); + await expect(page.getByText("No articles found", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Clear search" }).click(); + await expect(rows).toHaveCount(9); + expect((await a11y().analyze()).violations).toEqual([]); + await expect(page.getByRole("button", { name: "Edit interests", exact: true })).toHaveCount(0); + await page.getByRole("link", { name: "Research", exact: true }).click(); + await expect(page).toHaveURL(/\/deck$/); + await page.getByRole("link", { name: "Reader", exact: true }).click(); + await expect(page).toHaveURL(/\/inbox$/); + await expect(rows).toHaveCount(9); +}); -test('keeps article navigation readable without horizontal overflow', async ({ +test("keeps article navigation readable without horizontal overflow", async ({ page, a11y, }, testInfo) => { - await page.goto('/inbox') - await page.waitForLoadState('networkidle') + await page.goto("/inbox"); + await page.waitForLoadState("networkidle"); for (const width of [320, 375, 414, 768]) { - await page.setViewportSize({ width, height: 850 }) - await expect( - page.getByRole('textbox', { name: 'Search articles' }), - ).toBeVisible() - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) - await page.locator('.inbox-article-row').first().click() - const reader = page.getByRole('article', { - name: 'Thinking in the browser: a new workflow with AI agents', + await page.setViewportSize({ width, height: 850 }); + await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + await page.locator(".inbox-article-row").first().click(); + const reader = page.getByRole("article", { + name: "Thinking in the browser: a new workflow with AI agents", exact: true, - }) - await expect(reader).toBeVisible() - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) + }); + await expect(reader).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); await page.screenshot({ path: testInfo.outputPath(`inbox-reader-${width}.png`), - }) - if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) - const back = page.getByRole('button', { name: 'Back to articles' }) - if (await back.isVisible()) await back.click() + }); + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); + const back = page.getByRole("button", { name: "Back to articles" }); + if (await back.isVisible()) await back.click(); } -}) +}); diff --git a/e2e/integrations/research.test.ts b/e2e/integrations/research.test.ts index 6114bad..0f8b7f4 100644 --- a/e2e/integrations/research.test.ts +++ b/e2e/integrations/research.test.ts @@ -1,351 +1,378 @@ -import type { DeckColumn } from '../../src/features/decks/model' -import type { ResearchRun } from '../../src/features/research/model' -import { expect, test } from '../fixtures' -import { - closeMobileSidebar, - showDeckManagement, - showResearch, -} from '../workspace' +import type { DeckColumn } from "../../src/features/decks/model"; +import type { ResearchRun } from "../../src/features/research/model"; +import { expect, test } from "../fixtures"; +import { closeMobileSidebar, showDeckManagement, showResearch } from "../workspace"; -test('pushes the current research snapshot on each SSE connection', async ({ - page, -}) => { - await page.goto('/deck') +test("pushes the current research snapshot on each SSE connection", async ({ page }) => { + await page.goto("/deck"); const snapshots = await page.evaluate(async () => { const connect = () => new Promise((resolve, reject) => { - const events = new EventSource('/api/research/events') + const events = new EventSource("/api/research/events"); const timer = setTimeout(() => { - events.close() - reject(new Error('SSE timed out')) - }, 5000) + events.close(); + reject(new Error("SSE timed out")); + }, 5000); events.onmessage = (event) => { - clearTimeout(timer) - events.close() - resolve(JSON.parse(event.data)) - } + clearTimeout(timer); + events.close(); + resolve(JSON.parse(event.data)); + }; events.onerror = () => { - clearTimeout(timer) - events.close() - reject(new Error('SSE failed')) - } - }) - return [await connect(), await connect()] - }) + clearTimeout(timer); + events.close(); + reject(new Error("SSE failed")); + }; + }); + return [await connect(), await connect()]; + }); expect(snapshots).toEqual([ { configured: false, run: null, history: [] }, { configured: false, run: null, history: [] }, - ]) -}) + ]); +}); -test('restores the conversation and growing temporary deck from reconnect snapshots', async ({ +test("restores the conversation and growing temporary deck from reconnect snapshots", async ({ page, }) => { - await page.goto('/deck') - await showDeckManagement(page) - await page.getByRole('button', { name: 'Add column', exact: true }).click() - const accounts = page.getByRole('combobox', { - name: 'Connection profile', + await page.goto("/deck"); + await showDeckManagement(page); + await page.getByRole("button", { name: "Add column", exact: true }).click(); + const accounts = page.getByRole("combobox", { + name: "Connection profile", exact: true, - }) - await accounts.selectOption({ label: 'e2e' }) - const connectionId = await accounts.inputValue() - await page.keyboard.press('Escape') - await closeMobileSidebar(page) + }); + await accounts.selectOption({ label: "e2e" }); + const connectionId = await accounts.inputValue(); + await page.keyboard.press("Escape"); + await closeMobileSidebar(page); const run: ResearchRun = { - id: '36ad8cc7-318c-46ae-94fa-28d30336f027', - threadId: 'conversation-test', + id: "36ad8cc7-318c-46ae-94fa-28d30336f027", + threadId: "conversation-test", connectionIds: [connectionId], - topic: '既存の観点を使って調査して', - status: 'running', + topic: "既存の観点を使って調査して", + status: "running", startedAt: Date.now(), deckVersion: 1, citations: [], - message: '既存の検索条件を再利用しています。', + message: "既存の検索条件を再利用しています。", messages: [ - { id: 'user-1', role: 'user', text: '既存の観点を使って調査して' }, - { id: 'tool-1', role: 'tool', text: 'list_decks: 完了' }, + { id: "user-1", role: "user", text: "既存の観点を使って調査して" }, + { id: "tool-1", role: "tool", text: "list_decks: 完了" }, { - id: 'assistant-1', - role: 'assistant', - text: '既存の検索条件を再利用しています。', + id: "assistant-1", + role: "assistant", + text: "既存の検索条件を再利用しています。", }, ], deck: { - id: 'generated-plan', - title: '会話からの調査', + id: "generated-plan", + title: "会話からの調査", columns: [ { - id: 'one', - title: '最初の観点', + id: "one", + title: "最初の観点", connectionId, source: { - platform: 'twitter', - kind: 'search', - query: 'WebMCP', - product: 'Latest', + platform: "twitter", + kind: "search", + query: "WebMCP", + product: "Latest", following: false, }, }, ], }, - } - await page.route('**/api/research/events', (route) => + }; + await page.route("**/api/research/events*", (route) => route.fulfill({ - contentType: 'text/event-stream', + contentType: "text/event-stream", body: `data: ${JSON.stringify({ configured: true, run })}\n\n`, }), - ) - await page.reload() - await showResearch(page) - const conversation = page.getByRole('log', { name: 'Research conversation' }) - await expect(conversation).toContainText('既存の観点を使って調査して') - await expect(conversation).toContainText('list_decks: 完了') - await expect(conversation).toContainText('既存の検索条件を再利用しています。') - await expect( - conversation.locator('[data-slot="bubble-content"]'), - ).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') - await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) - run.deckVersion = 2 - run.status = 'complete' + ); + await page.goto(`/deck?run=${run.id}`); + await showResearch(page); + const conversation = page.getByRole("log", { name: "Research conversation" }); + await expect(conversation).toContainText("既存の観点を使って調査して"); + await expect(conversation).toContainText("list_decks: 完了"); + await expect(conversation).toContainText("既存の検索条件を再利用しています。"); + await expect(conversation.locator('[data-slot="bubble-content"]')).not.toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]); + run.deckVersion = 2; + run.status = "complete"; run.messages.push({ - id: 'assistant-2', - role: 'assistant', - text: '比較する観点を追加しました。', - }) + id: "assistant-2", + role: "assistant", + text: "比較する観点を追加しました。", + }); run.deck?.columns.push({ - id: 'two', - title: '比較の観点', + id: "two", + title: "比較の観点", connectionId, source: { - platform: 'twitter', - kind: 'search', - query: 'WebMCP lang:ja', - product: 'Latest', + platform: "twitter", + kind: "search", + query: "WebMCP lang:ja", + product: "Latest", following: false, }, - }) - await page.reload() - await showResearch(page) - await expect(conversation).toContainText('既存の観点を使って調査して') - await expect(conversation).toContainText('比較する観点を追加しました。') - await expect(page.locator('.deck-column h2')).toHaveText([ - '最初の観点', - '比較の観点', - ]) - await closeMobileSidebar(page) - await expect( - page.getByRole('button', { name: 'Save as deck', exact: true }), - ).toBeEnabled() - await showResearch(page) - const accountPicker = page.getByRole('button', { - name: 'Connected accounts · 1', - }) - await accountPicker.click() - await expect(accountPicker).toHaveAttribute('aria-expanded', 'true') - await expect( - page.getByRole('checkbox', { name: 'e2e · Twitter' }), - ).toBeChecked() - await accountPicker.click() - await expect(accountPicker).toHaveAttribute('aria-expanded', 'false') - await page.getByLabel('Message', { exact: true }).fill('この観点を詳しく') - await expect( - page.getByRole('button', { name: 'Send', exact: true }), - ).toBeEnabled() - await page.getByRole('button', { name: 'Close sidebar', exact: true }).click() - run.deckVersion = 3 - if (run.deck) run.deck.title = '閉じている間に更新された調査' - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - '閉じている間に更新された調査', - { timeout: 15000 }, - ) - await showResearch(page) - await expect(page.getByLabel('Message', { exact: true })).toHaveValue( - 'この観点を詳しく', - ) - await page.setViewportSize({ width: 600, height: 900 }) - await expect( - page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), - ).toHaveCount(1) - await showResearch(page) - await expect(page.getByLabel('Message', { exact: true })).toHaveValue( - 'この観点を詳しく', - ) - await page.getByRole('button', { name: 'Connected accounts · 1' }).click() - await page.getByRole('checkbox', { name: 'e2e · Twitter' }).uncheck() - await page.setViewportSize({ width: 1100, height: 900 }) - await expect( - page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), - ).toHaveCount(0) - await showResearch(page) - await expect(page.getByLabel('Message', { exact: true })).toHaveValue( - 'この観点を詳しく', - ) - await page.getByRole('button', { name: 'Connected accounts · 0' }).click() - await expect( - page.getByRole('checkbox', { name: 'e2e · Twitter' }), - ).not.toBeChecked() - await page.setViewportSize({ width: 600, height: 900 }) - await expect( - page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), - ).toHaveCount(1) - await showResearch(page) - await expect(page.getByLabel('Message', { exact: true })).toHaveValue( - 'この観点を詳しく', - ) - await page.getByRole('button', { name: 'Connected accounts · 0' }).click() - await expect( - page.getByRole('checkbox', { name: 'e2e · Twitter' }), - ).not.toBeChecked() -}) + }); + await page.reload(); + await showResearch(page); + await expect(conversation).toContainText("既存の観点を使って調査して"); + await expect(conversation).toContainText("比較する観点を追加しました。"); + await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点", "比較の観点"]); + await closeMobileSidebar(page); + await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled(); + await showResearch(page); + const accountPicker = page.getByRole("button", { + name: "Connected accounts · 1", + }); + await accountPicker.click(); + await expect(accountPicker).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).toBeChecked(); + await accountPicker.click(); + await expect(accountPicker).toHaveAttribute("aria-expanded", "false"); + await page.getByLabel("Message", { exact: true }).fill("この観点を詳しく"); + await expect(page.getByRole("button", { name: "Send", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "Close sidebar", exact: true }).click(); + run.deckVersion = 3; + if (run.deck) run.deck.title = "閉じている間に更新された調査"; + await expect(page.getByRole("heading", { level: 1 })).toHaveText("閉じている間に更新された調査", { + timeout: 15000, + }); + await showResearch(page); + await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); + await page.setViewportSize({ width: 600, height: 900 }); + await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1); + await showResearch(page); + await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); + await page.getByRole("button", { name: "Connected accounts · 1" }).click(); + await page.getByRole("checkbox", { name: "e2e · Twitter" }).uncheck(); + await page.setViewportSize({ width: 1100, height: 900 }); + await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(0); + await showResearch(page); + await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); + await page.getByRole("button", { name: "Connected accounts · 0" }).click(); + await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked(); + await page.setViewportSize({ width: 600, height: 900 }); + await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1); + await showResearch(page); + await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); + await page.getByRole("button", { name: "Connected accounts · 0" }).click(); + await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked(); +}); -test('rejects a different tailnet owner at the SSE endpoint', async ({ - request, -}) => { - const response = await request.get('/api/research/events', { - headers: { 'Tailscale-User-Login': 'someone-else@twitter-lite.invalid' }, - }) - expect(response.status()).toBe(403) -}) +test("rejects a different tailnet owner at the SSE endpoint", async ({ request }) => { + const response = await request.get("/api/research/events", { + headers: { "Tailscale-User-Login": "someone-else@twitter-lite.invalid" }, + }); + expect(response.status()).toBe(403); +}); -test('renders Markdown and reveals an unloaded citation in an offscreen deck column', async ({ +test("renders Markdown and reveals an unloaded citation in an offscreen deck column", async ({ page, a11y, }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }) - await page.goto('/deck') - await showDeckManagement(page) - await page.getByRole('button', { name: 'Add column', exact: true }).click() - const accounts = page.getByRole('combobox', { - name: 'Connection profile', + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/deck"); + await showDeckManagement(page); + await page.getByRole("button", { name: "Add column", exact: true }).click(); + const accounts = page.getByRole("combobox", { + name: "Connection profile", exact: true, - }) - await accounts.selectOption({ label: 'e2e' }) - const connectionId = await accounts.inputValue() - await page.keyboard.press('Escape') - await closeMobileSidebar(page) + }); + await accounts.selectOption({ label: "e2e" }); + const connectionId = await accounts.inputValue(); + await page.keyboard.press("Escape"); + await closeMobileSidebar(page); const source: DeckColumn = { - id: 'citation-column-2', - title: '引用の観点', + id: "citation-column-2", + title: "引用の観点", connectionId, source: { - platform: 'twitter', - kind: 'search', - query: 'citation-query-2', - product: 'Latest', + platform: "twitter", + kind: "search", + query: "citation-query-2", + product: "Latest", following: false, }, - } - const columns: DeckColumn[] = ['概要', '比較'].map((title, index) => ({ + }; + const columns: DeckColumn[] = ["概要", "比較"].map((title, index) => ({ id: `citation-column-${index}`, title, connectionId, source: { - platform: 'twitter', - kind: 'search', + platform: "twitter", + kind: "search", query: `citation-query-${index}`, - product: 'Latest', + product: "Latest", following: false, }, - })) - columns.push(source) + })); + columns.push(source); const run: ResearchRun = { - id: '973ae084-18ed-4674-8899-c75851e51d1b', - topic: '引用を確認', - status: 'complete', + id: "973ae084-18ed-4674-8899-c75851e51d1b", + topic: "引用を確認", + status: "complete", startedAt: Date.now(), deckVersion: 1, - message: '', - deck: { id: 'citation-deck', title: '引用付きの調査', columns }, + message: "", + deck: { id: "citation-deck", title: "引用付きの調査", columns }, messages: [ { - id: 'answer', - role: 'assistant', - text: '## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。', + id: "answer", + role: "assistant", + text: "## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。", }, ], citations: [ { column: source, post: { - key: 'twitter:123456789', - nativeId: '123456789', - platform: 'twitter', - url: 'https://twitter.com/author/status/123456789', - text: 'AIが調査時に取得した引用本文', - author: { name: '引用元の著者', handle: 'author' }, + key: "twitter:123456789", + nativeId: "123456789", + platform: "twitter", + url: "https://twitter.com/author/status/123456789", + text: "AIが調査時に取得した引用本文", + author: { name: "引用元の著者", handle: "author" }, }, }, ], - } - await page.route('**/api/research/events', (route) => + }; + await page.route("**/api/research/events*", (route) => route.fulfill({ - contentType: 'text/event-stream', + contentType: "text/event-stream", body: `data: ${JSON.stringify({ configured: true, run })}\n\n`, }), - ) - await page.reload() - await showResearch(page) - const chat = page.getByRole('log', { name: 'Research conversation' }) - await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible() - await expect(chat.getByRole('heading', { name: '調査結果' })).toHaveCSS( - 'font-weight', - '600', - ) - await expect(chat.locator('ul')).toHaveCSS('list-style-type', 'disc') - await expect( - chat.locator('strong').filter({ hasText: '重要な発見' }), - ).toBeVisible() - await expect(chat.getByRole('table')).toBeVisible() - await expect( - page.getByText('AIが調査時に取得した引用本文', { exact: true }), - ).toHaveCount(0) - await chat.getByRole('link', { name: '根拠の投稿' }).click() + ); + await page.goto(`/deck?run=${run.id}`); + await showResearch(page); + const chat = page.getByRole("log", { name: "Research conversation" }); + await expect(chat.getByRole("heading", { name: "調査結果" })).toBeVisible(); + await expect(chat.getByRole("heading", { name: "調査結果" })).toHaveCSS("font-weight", "600"); + await expect(chat.locator("ul")).toHaveCSS("list-style-type", "disc"); + await expect(chat.locator("strong").filter({ hasText: "重要な発見" })).toBeVisible(); + await expect(chat.getByRole("table")).toBeVisible(); + await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0); + await chat.getByRole("link", { name: "根拠の投稿" }).click(); const card = page.locator( '.research-post[data-post-url="https://twitter.com/author/status/123456789"]', - ) - await expect(card).toBeVisible() - await expect(card).toHaveClass(/citation-highlight/) - await expect(card).toBeFocused() - await expect(card).toContainText('Post saved during research') - await expect(card).toContainText('AIが調査時に取得した引用本文') + ); + await expect(card).toBeVisible(); + await expect(card).toHaveClass(/citation-highlight/); + await expect(card).toBeFocused(); + await expect(card).toContainText("Post saved during research"); + await expect(card).toContainText("AIが調査時に取得した引用本文"); expect( - await page.locator('.deck-board').evaluate((element) => element.scrollLeft), - ).toBeGreaterThan(0) - expect(page.context().pages()).toHaveLength(1) - expect((await a11y().analyze()).violations).toEqual([]) -}) + await page.locator(".deck-board").evaluate((element) => element.scrollLeft), + ).toBeGreaterThan(0); + expect(page.context().pages()).toHaveLength(1); + await expect(page).toHaveURL(/citation=twitter%3A123456789/); + const bookmarked = new URL(page.url()); + bookmarked.searchParams.set("run", run.id); + bookmarked.searchParams.set("deck", "citation-deck"); + const submitted: string[] = []; + page.on("request", (request) => { + if (request.method() === "POST") submitted.push(request.url()); + }); + await page.goto(bookmarked.toString()); + await expect(card).toBeVisible(); + await expect(card).toHaveClass(/citation-highlight/); + await expect(page).toHaveURL(/citation=twitter%3A123456789/); + await expect(page.locator(".deck-column")).toHaveCount(3); + expect(submitted).toEqual([]); + expect((await a11y().analyze()).violations).toEqual([]); +}); -test('explains unconfigured Codex and prevents starting research without affecting the deck', async ({ +test("explains unconfigured Codex and prevents starting research without affecting the deck", async ({ page, a11y, }) => { - await page.goto('/deck') - const heading = page.getByRole('heading', { level: 1 }) - await expect(heading).toBeVisible() - const originalTitle = await heading.textContent() - await showResearch(page) - const panel = page.getByRole('complementary', { name: 'Research chat' }) - await expect(panel).toBeVisible() + await page.goto("/deck"); + const heading = page.getByRole("heading", { level: 1 }); + await expect(heading).toBeVisible(); + const originalTitle = await heading.textContent(); + await showResearch(page); + const panel = page.getByRole("complementary", { name: "Research chat" }); + await expect(panel).toBeVisible(); await expect( - panel.getByText( - 'The connection to Codex on your home server is not configured.', - ), - ).toBeVisible() - await expect( - panel.getByRole('button', { name: 'Send', exact: true }), - ).toBeDisabled() - await expect(panel.getByLabel('Message', { exact: true })).toBeDisabled() - const accessibility = await a11y().analyze() - expect(accessibility.violations).toEqual([]) - await closeMobileSidebar(page) - await expect(heading).toHaveText(originalTitle ?? '') - await showDeckManagement(page) - await page.getByRole('button', { name: 'Create deck', exact: true }).click() - await page.getByLabel('New deck name').fill('手動の調査') - await page.getByRole('button', { name: 'Create', exact: true }).click() - await closeMobileSidebar(page) - await expect(heading).toHaveText('手動の調査') - await showResearch(page) - await expect(panel).toBeVisible() -}) + panel.getByText("The connection to Codex on your home server is not configured."), + ).toBeVisible(); + await expect(panel.getByRole("button", { name: "Send", exact: true })).toBeDisabled(); + await expect(panel.getByLabel("Message", { exact: true })).toBeDisabled(); + const accessibility = await a11y().analyze(); + expect(accessibility.violations).toEqual([]); + await closeMobileSidebar(page); + await expect(heading).toHaveText(originalTitle ?? ""); + await showDeckManagement(page); + await page.getByRole("button", { name: "Create deck", exact: true }).click(); + await page.getByLabel("New deck name").fill("手動の調査"); + await page.getByRole("button", { name: "Create", exact: true }).click(); + await closeMobileSidebar(page); + await expect(heading).toHaveText("手動の調査"); + await showResearch(page); + await expect(panel).toBeVisible(); +}); + +test("keeps browser conversation selection independent and restores it on reload", async ({ + page, + browser, + context, +}) => { + const { openDatabase } = await import("../../src/features/storage/database.server"); + const { createResearchStore } = await import("../../src/features/research/repository.server"); + const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? ""); + const store = createResearchStore(db); + const first = crypto.randomUUID(); + const second = crypto.randomUUID(); + for (const [id, text] of [ + [first, "Browser one conversation"], + [second, "Browser two conversation"], + ] as const) { + store.save({ + id, + topic: text, + status: "complete", + startedAt: Date.now(), + deckVersion: 0, + message: text, + messages: [{ id: `${id}-message`, role: "assistant", text }], + citations: [], + }); + } + const other = await browser.newContext({ + storageState: await context.storageState(), + extraHTTPHeaders: { "Tailscale-User-Login": "owner@twitter-lite.invalid" }, + }); + try { + const secondPage = await other.newPage(); + await page.goto(`/deck?run=${first}`, { waitUntil: "domcontentloaded" }); + await showResearch(page); + await secondPage.goto(`/deck?run=${second}`, { + waitUntil: "domcontentloaded", + }); + await showResearch(secondPage); + await expect(page.getByRole("log", { name: "Research conversation" })).toContainText( + "Browser one conversation", + ); + await expect(secondPage.getByRole("log", { name: "Research conversation" })).toContainText( + "Browser two conversation", + ); + await secondPage.getByRole("button", { name: "New chat", exact: true }).click(); + await expect(secondPage).toHaveURL(/run=new/); + await page.reload({ waitUntil: "domcontentloaded" }); + await showResearch(page); + await expect(page).toHaveURL(new RegExp(`run=${first}`)); + await expect(page.getByRole("log", { name: "Research conversation" })).toContainText( + "Browser one conversation", + ); + } finally { + await other.close(); + db.$client.prepare("DELETE FROM research_sessions WHERE id IN (?, ?)").run(first, second); + db.$client.close(); + } +}); diff --git a/e2e/integrations/support.test.ts b/e2e/integrations/support.test.ts index d49cfed..399fa0a 100644 --- a/e2e/integrations/support.test.ts +++ b/e2e/integrations/support.test.ts @@ -1,158 +1,127 @@ -import { expect, test } from '../fixtures' +import { expect, test } from "../fixtures"; -const reviewSubject = 'Schedule next week’s design review' -const coffeeSubject = 'Catch up over coffee' +const reviewSubject = "Schedule next week’s design review"; +const coffeeSubject = "Catch up over coffee"; -test('reviews an edited reply and keeps the simulated result in history', async ({ +test("reviews an edited reply and keeps the simulated result in history", async ({ page, a11y, }) => { - await page.goto('/support') - await page.waitForLoadState('networkidle') - const rows = page.locator('.support-row') - await expect(rows).toHaveCount(3) - await rows.filter({ hasText: reviewSubject }).click() - await page.getByRole('button', { name: 'Polite', exact: true }).click() - const reply = page.getByRole('textbox', { name: 'Reply', exact: true }) - await expect(reply).toHaveValue(/Thank you for sharing/) - const edited = - 'I reviewed the materials. Let’s discuss the first-time experience.' - await reply.fill(edited) - const slot = 'Mon, Oct 5, 14:00–14:30' - await page - .getByRole('combobox', { name: 'Suggested times' }) - .selectOption(slot) - await page.getByRole('button', { name: 'Add to reply' }).click() - await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`) - await page.getByRole('button', { name: 'Review reply' }).click() - const dialog = page.getByRole('dialog', { name: 'Review reply' }) - await expect(dialog).toContainText(edited) - await expect(dialog).toContainText(slot) - await expect(dialog).toContainText('No message will be sent') - expect((await a11y().analyze()).violations).toEqual([]) - await dialog.getByRole('button', { name: 'Back', exact: true }).click() - await expect(reply).toHaveValue(new RegExp(edited)) - await page.getByRole('button', { name: 'Review reply' }).click() - await dialog.getByRole('button', { name: 'Try sending' }).click() - await expect(dialog).toHaveCount(0) - await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0) - await page.getByRole('button', { name: 'History', exact: true }).click() - await rows.filter({ hasText: reviewSubject }).click() - await expect(page.locator('.support-outcome')).toContainText(edited) - await expect(page.locator('.support-outcome')).toContainText(slot) -}) + await page.goto("/support"); + await page.waitForLoadState("networkidle"); + const rows = page.locator(".support-row"); + await expect(rows).toHaveCount(3); + await rows.filter({ hasText: reviewSubject }).click(); + await page.getByRole("button", { name: "Polite", exact: true }).click(); + const reply = page.getByRole("textbox", { name: "Reply", exact: true }); + await expect(reply).toHaveValue(/Thank you for sharing/); + const edited = "I reviewed the materials. Let’s discuss the first-time experience."; + await reply.fill(edited); + const slot = "Mon, Oct 5, 14:00–14:30"; + await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot); + await page.getByRole("button", { name: "Add to reply" }).click(); + await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`); + await page.getByRole("button", { name: "Review reply" }).click(); + const dialog = page.getByRole("dialog", { name: "Review reply" }); + await expect(dialog).toContainText(edited); + await expect(dialog).toContainText(slot); + await expect(dialog).toContainText("No message will be sent"); + expect((await a11y().analyze()).violations).toEqual([]); + await dialog.getByRole("button", { name: "Back", exact: true }).click(); + await expect(reply).toHaveValue(new RegExp(edited)); + await page.getByRole("button", { name: "Review reply" }).click(); + await dialog.getByRole("button", { name: "Try sending" }).click(); + await expect(dialog).toHaveCount(0); + await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0); + await page.getByRole("button", { name: "History", exact: true }).click(); + await rows.filter({ hasText: reviewSubject }).click(); + await expect(page.locator(".support-outcome")).toContainText(edited); + await expect(page.locator(".support-outcome")).toContainText(slot); +}); -test('defers a conversation and restores it to the current queue', async ({ - page, -}) => { - await page.goto('/support') - await page.waitForLoadState('networkidle') - const rows = page.locator('.support-row') - await rows.filter({ hasText: coffeeSubject }).click() +test("defers a conversation and restores it to the current queue", async ({ page }) => { + await page.goto("/support"); + await page.waitForLoadState("networkidle"); + const rows = page.locator(".support-row"); + await rows.filter({ hasText: coffeeSubject }).click(); await page - .getByRole('textbox', { name: 'Reply', exact: true }) - .fill('土曜の予定を確認してから返事するね。') - await page.getByRole('button', { name: 'Leave for later' }).click() - await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0) - await page.getByRole('button', { name: 'Later', exact: true }).click() - await rows.filter({ hasText: coffeeSubject }).click() - await expect( - page.getByRole('textbox', { name: 'Reply', exact: true }), - ).toHaveValue('土曜の予定を確認してから返事するね。') - await page.getByRole('button', { name: 'Move to Now' }).click() - const back = page.getByRole('button', { name: 'Back to messages' }) - if (await back.isVisible()) await back.click() - await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible() - await expect( - page.getByRole('button', { name: 'Now', exact: true }), - ).toHaveAttribute('aria-pressed', 'true') -}) + .getByRole("textbox", { name: "Reply", exact: true }) + .fill("土曜の予定を確認してから返事するね。"); + await page.getByRole("button", { name: "Leave for later" }).click(); + await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await rows.filter({ hasText: coffeeSubject }).click(); + await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue( + "土曜の予定を確認してから返事するね。", + ); + await page.getByRole("button", { name: "Move to Now" }).click(); + const back = page.getByRole("button", { name: "Back to messages" }); + if (await back.isVisible()) await back.click(); + await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible(); + await expect(page.getByRole("button", { name: "Now", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); +}); -test('keeps corrections as new records and summarizes only selected current facts', async ({ +test("keeps corrections as new records and summarizes only selected current facts", async ({ page, a11y, }) => { - await page.goto('/journal') - await page.waitForLoadState('networkidle') - await expect( - page.getByRole('button', { name: 'Save entry', exact: true }), - ).toBeDisabled() + await page.goto("/journal"); + await page.waitForLoadState("networkidle"); + await expect(page.getByRole("button", { name: "Save entry", exact: true })).toBeDisabled(); + await page.getByRole("textbox", { name: "Entry text" }).fill("次はレビュー資料の導線を確認する"); + await page.getByRole("button", { name: "Save entry", exact: true }).click(); + await expect(page.getByRole("article", { name: "Note entry" })).toContainText( + "Occurred: Unknown", + ); + await page.getByRole("button", { name: "Weight", exact: true }).click(); + await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2"); + await page.getByRole("textbox", { name: "Source" }).fill("自宅の体重計"); + await page.getByRole("button", { name: "Save entry", exact: true }).click(); await page - .getByRole('textbox', { name: 'Entry text' }) - .fill('次はレビュー資料の導線を確認する') - await page.getByRole('button', { name: 'Save entry', exact: true }).click() - await expect(page.getByRole('article', { name: 'Note entry' })).toContainText( - 'Occurred: Unknown', - ) - await page.getByRole('button', { name: 'Weight', exact: true }).click() - await page - .getByRole('spinbutton', { name: 'Weight', exact: true }) - .fill('65.2') - await page.getByRole('textbox', { name: 'Source' }).fill('自宅の体重計') - await page.getByRole('button', { name: 'Save entry', exact: true }).click() - await page - .getByRole('article', { name: 'Weight entry' }) - .getByRole('button', { name: 'Correct' }) - .click() - const correction = page.getByRole('dialog', { name: 'Correct entry' }) - await correction - .getByRole('spinbutton', { name: 'Weight', exact: true }) - .fill('65.8') - await correction.getByRole('button', { name: 'Add correction' }).click() - const weights = page.getByRole('article', { name: 'Weight entry' }) - await expect(weights).toHaveCount(2) - await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('Original') - await expect(weights.filter({ hasText: '65.8 kg' })).toContainText( - 'Correction', - ) - await page - .getByRole('button', { name: 'Summarize entries', exact: true }) - .click() - const review = page.getByRole('dialog', { name: 'Summarize entries' }) - const summary = review.getByRole('textbox', { name: 'Entry summary' }) - await expect(summary).toHaveValue(/65.8 kg/) - await expect(summary).not.toHaveValue(/65.2 kg/) - await expect(summary).toHaveValue(/Source: 自宅の体重計/) - await expect(summary).toHaveValue( - /Entries with an unknown event time: 2 entries/, - ) - await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck() - await expect(summary).not.toHaveValue(/次はレビュー資料/) + .getByRole("article", { name: "Weight entry" }) + .getByRole("button", { name: "Correct" }) + .click(); + const correction = page.getByRole("dialog", { name: "Correct entry" }); + await correction.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.8"); + await correction.getByRole("button", { name: "Add correction" }).click(); + const weights = page.getByRole("article", { name: "Weight entry" }); + await expect(weights).toHaveCount(2); + await expect(weights.filter({ hasText: "65.2 kg" })).toContainText("Original"); + await expect(weights.filter({ hasText: "65.8 kg" })).toContainText("Correction"); + await page.getByRole("button", { name: "Summarize entries", exact: true }).click(); + const review = page.getByRole("dialog", { name: "Summarize entries" }); + const summary = review.getByRole("textbox", { name: "Entry summary" }); + await expect(summary).toHaveValue(/65.8 kg/); + await expect(summary).not.toHaveValue(/65.2 kg/); + await expect(summary).toHaveValue(/Source: 自宅の体重計/); + await expect(summary).toHaveValue(/Entries with an unknown event time: 2 entries/); + await review.getByRole("checkbox", { name: /次はレビュー資料/ }).uncheck(); + await expect(summary).not.toHaveValue(/次はレビュー資料/); await expect(summary).toHaveValue( /Medication and weight are unknown for periods without entries/, - ) - expect((await a11y().analyze()).violations).toEqual([]) -}) + ); + expect((await a11y().analyze()).violations).toEqual([]); +}); -test('fits communication and journal interactions in narrow screens', async ({ - page, - a11y, -}) => { +test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => { for (const width of [320, 375, 768]) { - await page.setViewportSize({ width, height: 850 }) - await page.goto('/support') - await page.waitForLoadState('networkidle') - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) - await page.locator('.support-row').first().click() - await expect( - page.getByRole('textbox', { name: 'Reply', exact: true }), - ).toBeVisible() - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) - if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) - const back = page.getByRole('button', { name: 'Back to messages' }) - if (await back.isVisible()) await back.click() - await page.getByRole('link', { name: 'Journal', exact: true }).click() - await page.getByRole('button', { name: 'Weight', exact: true }).click() - await expect( - page.getByRole('spinbutton', { name: 'Weight', exact: true }), - ).toBeVisible() - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) - if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) + await page.setViewportSize({ width, height: 850 }); + await page.goto("/support"); + await page.waitForLoadState("networkidle"); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + await page.locator(".support-row").first().click(); + await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); + const back = page.getByRole("button", { name: "Back to messages" }); + if (await back.isVisible()) await back.click(); + await page.getByRole("link", { name: "Journal", exact: true }).click(); + await page.getByRole("button", { name: "Weight", exact: true }).click(); + await expect(page.getByRole("spinbutton", { name: "Weight", exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); } -}) +}); diff --git a/e2e/integrations/url-state.test.ts b/e2e/integrations/url-state.test.ts new file mode 100644 index 0000000..f59cdeb --- /dev/null +++ b/e2e/integrations/url-state.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "../fixtures"; + +test("restores article selection with browser history and reload", async ({ page }) => { + await page.goto("/inbox", { waitUntil: "networkidle" }); + const rows = page.locator(".inbox-article-row"); + const heading = await rows.nth(1).locator(".inbox-row-title").innerText(); + await rows.nth(1).click(); + await expect(page.locator(".inbox-article-title")).toHaveText(heading); + const selectedUrl = page.url(); + expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy(); + await page.reload({ waitUntil: "networkidle" }); + await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? ""); + await page.goBack(); + await expect(page.locator(".inbox-workspace")).toHaveAttribute("data-reading", "false"); + await page.goForward(); + await expect(page).toHaveURL(selectedUrl); + await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? ""); +}); + +test("restores search and replaces typing history", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByRole("link", { name: "Reader", exact: true }).click(); + const search = page.getByRole("textbox", { name: "Search articles" }); + await search.pressSequentially("browser"); + await expect(search).toHaveValue("browser"); + await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser"); + const count = await page.locator(".inbox-article-row").count(); + await page.reload({ waitUntil: "networkidle" }); + await expect(search).toHaveValue("browser"); + await expect(page.locator(".inbox-article-row")).toHaveCount(count); + await page.goBack(); + await expect(page).toHaveURL("http://127.0.0.1:4173/"); +}); + +test("restores account dialog without losing underlying article", async ({ page }) => { + await page.goto("/inbox", { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Manage connected accounts" }).click(); + await expect.poll(() => new URL(page.url()).searchParams.get("accounts")).toBe("true"); + await page.reload({ waitUntil: "networkidle" }); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.goBack(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.goForward(); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("restores a message view and conversation from its URL", async ({ page }) => { + await page.goto("/support", { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "All", exact: true }).click(); + const row = page.locator(".support-row").nth(1); + const heading = await row.locator("span").first().innerText(); + await row.click(); + await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading); + const url = page.url(); + expect(new URL(url).searchParams.get("view")).toBe("all"); + expect(new URL(url).searchParams.get("conversation")).toBeTruthy(); + await page.reload({ waitUntil: "networkidle" }); + await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading ?? ""); + await expect(page.locator(".support-workspace")).toHaveAttribute("data-reading", "true"); +}); + +test("keeps a deep link through login", async ({ page, context }) => { + await context.clearCookies(); + await page.goto("/inbox?q=browser", { waitUntil: "networkidle" }); + await expect(page).toHaveURL(/\/login\?returnTo=/); + await page.getByLabel("Email address").fill("owner@workspace.invalid"); + await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026"); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page).toHaveURL(/\/inbox\?q=browser$/); + await expect(page.getByRole("textbox", { name: "Search articles" })).toHaveValue("browser"); +}); + +test("debounces search and cancels pending input on history navigation", async ({ page }) => { + await page.goto("/support", { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "All", exact: true }).click(); + await expect(page).toHaveURL(/view=all/); + await page.clock.install(); + const input = page.getByRole("textbox", { name: "Search messages" }); + await input.fill("not-committed"); + await expect(input).toHaveValue("not-committed"); + expect(new URL(page.url()).searchParams.has("q")).toBe(false); + await page.clock.runFor(200); + expect(new URL(page.url()).searchParams.has("q")).toBe(false); + await page.goBack(); + await expect(page).toHaveURL("http://127.0.0.1:4173/support"); + await page.clock.runFor(400); + await expect(input).toHaveValue(""); + expect(new URL(page.url()).searchParams.has("q")).toBe(false); + await input.fill("final search"); + await page.clock.runFor(300); + await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("final search"); +}); + +test("waits for IME composition to finish before searching", async ({ page }) => { + await page.goto("/inbox", { waitUntil: "networkidle" }); + await page.clock.install(); + const input = page.getByRole("textbox", { name: "Search articles" }); + await input.dispatchEvent("compositionstart"); + await input.fill("browser"); + await page.clock.runFor(1000); + await expect(input).toHaveValue("browser"); + expect(new URL(page.url()).searchParams.has("q")).toBe(false); + await input.dispatchEvent("compositionend", { data: "browser" }); + await page.clock.runFor(300); + await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser"); +}); diff --git a/e2e/integrations/vitals.test.ts b/e2e/integrations/vitals.test.ts index fffaa31..8b0547b 100644 --- a/e2e/integrations/vitals.test.ts +++ b/e2e/integrations/vitals.test.ts @@ -1,115 +1,124 @@ -import { expect, test } from '../fixtures' +import { expect, test } from "../fixtures"; -test('filters body systems and opens lab history', async ({ page, a11y }) => { - await page.goto('/vitals') - await page.waitForLoadState('networkidle') - await expect( - page.getByRole('heading', { name: 'Body', exact: true }), - ).toBeVisible() - const systems = page.locator('.vital-system-card') - await expect(systems).toHaveCount(8) +test("filters body systems and opens lab history", async ({ page, a11y }) => { + await page.goto("/vitals"); + await page.waitForLoadState("networkidle"); + await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible(); + const systems = page.locator(".vital-system-card"); + await expect(systems).toHaveCount(8); await page - .locator('.vital-body-page > .vital-tabs') - .getByRole('button', { name: 'Cardiovascular', exact: true }) - .click() - await expect(systems).toHaveCount(1) - await systems.first().click() - const dialog = page.getByRole('dialog', { name: 'Cardiovascular' }) - await expect(dialog).toContainText('68') - await dialog - .getByRole('button', { name: 'Close', exact: true }) - .last() - .click() - await page.getByRole('button', { name: 'Lab history', exact: true }).click() - await expect(page.locator('.vital-history')).toContainText('Jul 28') - await expect(page.locator('.vital-history')).toContainText('93') - await page.getByRole('button', { name: 'Systems', exact: true }).click() + .getByRole("group", { name: "Body systems", exact: true }) + .getByRole("button", { name: "Cardiovascular", exact: true }) + .click(); + await expect(systems).toHaveCount(1); + await expect(page).toHaveURL(/vitalTab=Cardiovascular/); + await page.reload(); + await page.waitForLoadState("networkidle"); + await expect(systems).toHaveCount(1); + await systems.first().click(); + const dialog = page.getByRole("dialog", { name: "Cardiovascular" }); + await expect(dialog).toContainText("68"); + await page.goBack(); + await expect(dialog).not.toBeVisible(); + await page.goForward(); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Close", exact: true }).last().click(); + await page.getByRole("button", { name: "Lab history", exact: true }).click(); + await expect(page.locator(".vital-history")).toContainText("Jul 28"); + await expect(page.locator(".vital-history")).toContainText("93"); + await page.reload(); + await page.waitForLoadState("networkidle"); + await expect(page.locator(".vital-history")).toContainText("Jul 28"); + await page.getByRole("button", { name: "Systems", exact: true }).click(); await page - .locator('.vital-body-page > .vital-tabs') - .getByRole('button', { name: 'Overview', exact: true }) - .click() - await expect(systems).toHaveCount(8) - expect((await a11y().analyze()).violations).toEqual([]) -}) + .getByRole("group", { name: "Body systems", exact: true }) + .getByRole("button", { name: "Overview", exact: true }) + .click(); + await expect(systems).toHaveCount(8); + expect((await a11y().analyze()).violations).toEqual([]); +}); -test('explores environment charts and simulated climate controls', async ({ - page, - a11y, -}) => { - await page.goto('/vitals') - await page.waitForLoadState('networkidle') - const menu = page.getByRole('button', { name: 'Open Health menu' }) - if (await menu.isVisible()) await menu.click() +test("explores environment charts and simulated climate controls", async ({ page, a11y }) => { + await page.goto("/vitals"); + await page.waitForLoadState("networkidle"); + const menu = page.getByRole("button", { name: "Open Health menu" }); + if (await menu.isVisible()) await menu.click(); await page - .getByRole('complementary', { name: 'Health navigation' }) - .getByRole('button', { name: 'Environment', exact: true }) - .click() - await expect( - page.getByRole('heading', { name: 'Environment', exact: true }), - ).toBeVisible() - await page.getByRole('button', { name: '7D', exact: true }).click() + .getByRole("complementary", { name: "Health navigation" }) + .getByRole("button", { name: "Environment", exact: true }) + .click(); + await expect(page.getByRole("heading", { name: "Environment", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "7D", exact: true }).click(); await page - .getByRole('group', { name: 'Trend metric' }) - .getByRole('button', { name: 'CO₂', exact: true }) - .click() + .getByRole("group", { name: "Trend metric" }) + .getByRole("button", { name: "CO₂", exact: true }) + .click(); await expect( - page.getByRole('img', { name: '7D CO₂ simulated environmental readings' }), - ).toBeVisible() - await page - .locator('.vital-metric') - .filter({ hasText: 'Room temperature' }) - .click() - const dialog = page.getByRole('dialog', { name: 'Room temperature' }) - await expect(dialog).toContainText('64.0') - await dialog.getByRole('button', { name: 'Back to overview' }).click() - await page.getByRole('button', { name: 'Sync now', exact: true }).click() + page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }), + ).toBeVisible(); + await page.reload(); + await page.waitForLoadState("networkidle"); await expect( - page.getByRole('status').filter({ hasText: 'Sample readings refreshed' }), - ).toBeVisible() - expect((await a11y().analyze()).violations).toEqual([]) - await page - .getByRole('button', { name: 'Climate control', exact: true }) - .click() - await page.getByRole('button', { name: 'Cool', exact: true }).click() + page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }), + ).toBeVisible(); + await page.locator(".vital-metric").filter({ hasText: "Room temperature" }).click(); + const dialog = page.getByRole("dialog", { name: "Room temperature" }); + await expect(dialog).toContainText("64.0"); + await page.reload(); + await page.waitForLoadState("networkidle"); + await expect(dialog).toContainText("64.0"); + await dialog.getByRole("button", { name: "Back to overview" }).click(); + await page.getByRole("button", { name: "Sync now", exact: true }).click(); await expect( - page.getByRole('button', { name: 'Cool', exact: true }), - ).toHaveAttribute('aria-pressed', 'true') - const temperature = page.getByRole('slider', { name: 'Target temperature' }) - await temperature.focus() - await temperature.press('ArrowRight') - await expect(temperature).toHaveValue('69') - await page.getByRole('button', { name: 'Air purifier · On' }).click() - await expect( - page.getByRole('button', { name: 'Air purifier · Off' }), - ).toHaveAttribute('aria-pressed', 'false') -}) + page.getByRole("status").filter({ hasText: "Sample readings refreshed" }), + ).toBeVisible(); + expect((await a11y().analyze()).violations).toEqual([]); + await page.getByRole("button", { name: "Climate control", exact: true }).click(); + await page.getByRole("button", { name: "Cool", exact: true }).click(); + await expect(page.getByRole("button", { name: "Cool", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + const temperature = page.getByRole("slider", { name: "Target temperature" }); + await temperature.focus(); + await temperature.press("ArrowRight"); + await expect(temperature).toHaveValue("69"); + await page.getByRole("button", { name: "Air purifier · On" }).click(); + await expect(page.getByRole("button", { name: "Air purifier · Off" })).toHaveAttribute( + "aria-pressed", + "false", + ); + await page.reload(); + await page.waitForLoadState("networkidle"); + await expect(temperature).toHaveValue("68"); + await expect(page.getByRole("button", { name: "Auto", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(page.getByRole("button", { name: "Air purifier · On" })).toHaveAttribute( + "aria-pressed", + "true", + ); +}); -test('keeps both dashboards within the viewport', async ({ - page, -}, testInfo) => { - await page.goto('/vitals') - await page.waitForLoadState('networkidle') +test("keeps both dashboards within the viewport", async ({ page }, testInfo) => { + await page.goto("/vitals"); + await page.waitForLoadState("networkidle"); for (const width of [375, 768, 1440]) { - await page.setViewportSize({ width, height: 1000 }) - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) - await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) }) - const menu = page.getByRole('button', { name: 'Open Health menu' }) - if (await menu.isVisible()) await menu.click() - const navigation = page.getByRole('complementary', { - name: 'Health navigation', - }) - await navigation - .getByRole('button', { name: 'Environment', exact: true }) - .click() - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) + await page.setViewportSize({ width, height: 1000 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) }); + const menu = page.getByRole("button", { name: "Open Health menu" }); + if (await menu.isVisible()) await menu.click(); + const navigation = page.getByRole("complementary", { + name: "Health navigation", + }); + await navigation.getByRole("button", { name: "Environment", exact: true }).click(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); await page.screenshot({ path: testInfo.outputPath(`environment-${width}.png`), - }) - if (await menu.isVisible()) await menu.click() - await navigation.getByRole('button', { name: 'Body', exact: true }).click() + }); + if (await menu.isVisible()) await menu.click(); + await navigation.getByRole("button", { name: "Body", exact: true }).click(); } -}) +}); diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index 4136494..77d8739 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -1,30 +1,28 @@ -import type { Page } from '@playwright/test' -import Database from 'better-sqlite3' -import { expect, test } from '../fixtures' +import type { Page } from "@playwright/test"; +import Database from "better-sqlite3"; +import { expect, test } from "../fixtures"; type NativeTesting = { - listTools(): { name: string }[] - executeTool(name: string, input: string): Promise -} + listTools(): { name: string }[]; + executeTool(name: string, input: string): Promise; +}; type ToolResult = { - content: { type: string; text: string }[] - isError?: boolean -} + content: { type: string; text: string }[]; + isError?: boolean; +}; test.use({ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, - args: ['--enable-blink-features=WebMCP,WebMCPTesting'], + args: ["--enable-blink-features=WebMCP,WebMCPTesting"], }, -}) +}); async function toolNames(page: Page) { return page.evaluate(() => - ( - navigator as Navigator & { modelContextTesting: NativeTesting } - ).modelContextTesting + (navigator as Navigator & { modelContextTesting: NativeTesting }).modelContextTesting .listTools() .map((tool) => tool.name) .sort(), - ) + ); } async function executeTool( page: Page, @@ -37,296 +35,249 @@ async function executeTool( navigator as Navigator & { modelContextTesting: NativeTesting } ).modelContextTesting.executeTool(name, JSON.stringify(input)), { name, input }, - ) - expect(result).not.toBeNull() - return JSON.parse(result ?? '{}') + ); + expect(result).not.toBeNull(); + return JSON.parse(result ?? "{}"); } function decoded(result: ToolResult) { - return JSON.parse(result.content[0]?.text ?? '{}') + return JSON.parse(result.content[0]?.text ?? "{}"); } const column = (title: string, connectionId: string) => ({ title, connectionId, - source: { kind: 'search', query: 'WebMCP' }, -}) + source: { kind: "search", query: "WebMCP" }, +}); function savedCount() { - const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? '', { + const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? "", { readonly: true, - }) + }); try { - return db.prepare('SELECT count(*) AS count FROM decks').get() + return db.prepare("SELECT count(*) AS count FROM decks").get(); } finally { - db.close() + db.close(); } } -let accounts: Record -let pageErrors: string[] +let accounts: Record; +let pageErrors: string[]; test.beforeEach(async ({ page }) => { - pageErrors = [] - page.on('pageerror', (error) => pageErrors.push(error.message)) - await page.goto('/deck') + pageErrors = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await page.goto("/deck"); await expect .poll(() => toolNames(page)) .toEqual([ - 'delete_deck', - 'get_column_posts', - 'get_deck', - 'list_connections', - 'list_decks', - 'load_more_column', - 'save_deck', - 'select_deck', - 'set_deck', - ]) + "delete_deck", + "get_column_posts", + "get_deck", + "list_connections", + "list_decks", + "load_more_column", + "save_deck", + "select_deck", + "set_deck", + ]); await expect - .poll( - async () => - decoded(await executeTool(page, 'list_connections')).connections - ?.length, - ) - .toBe(2) + .poll(async () => decoded(await executeTool(page, "list_connections")).connections?.length) + .toBe(2); accounts = Object.fromEntries( - decoded(await executeTool(page, 'list_connections')).connections.map( - (connection: { displayName: string; id: string }) => [ - connection.displayName, - connection.id, - ], + decoded(await executeTool(page, "list_connections")).connections.map( + (connection: { displayName: string; id: string }) => [connection.displayName, connection.id], ), - ) -}) -test.afterEach(() => expect(pageErrors).toEqual([])) + ); +}); +test.afterEach(() => expect(pageErrors).toEqual([])); -test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({ +test("creates temporary research, edits it, persists explicitly and reopens it on another device", async ({ page, browser, baseURL, }) => { - const created = await executeTool(page, 'set_deck', { - title: 'WebMCPの反応', + const created = await executeTool(page, "set_deck", { + title: "WebMCPの反応", columns: [ - { ...column('日本語', accounts.e2e ?? ''), id: 'new' }, - column('別の接続', accounts.alternate ?? ''), + { ...column("日本語", accounts.e2e ?? ""), id: "new" }, + column("別の接続", accounts.alternate ?? ""), ], - }) - expect(created.isError).not.toBe(true) - const deck = decoded(created).deck - expect(deck.persisted).toBe(false) - expect(savedCount()).toEqual({ count: 0 }) - await expect( - page.getByText('alternate · Latest · all page 1', { exact: true }), - ).toBeVisible() + }); + expect(created.isError).not.toBe(true); + const deck = decoded(created).deck; + expect(deck.persisted).toBe(false); + expect(savedCount()).toEqual({ count: 0 }); + await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible(); await page - .getByRole('region', { name: '日本語', exact: true }) - .getByRole('button', { name: 'Actions for 日本語' }) - .click() - await page.getByRole('menuitem', { name: 'Edit', exact: true }).click() - const replaced = await executeTool(page, 'set_deck', { + .getByRole("region", { name: "日本語", exact: true }) + .getByRole("button", { name: "Actions for 日本語" }) + .click(); + await page.getByRole("menuitem", { name: "Edit", exact: true }).click(); + const replaced = await executeTool(page, "set_deck", { deckId: deck.id, - title: '更新した調査', + title: "更新した調査", columns: [deck.columns[1]], - }) - expect(replaced.isError).not.toBe(true) - await expect(page.getByRole('dialog')).toHaveCount(0) - expect(savedCount()).toEqual({ count: 0 }) - expect( - (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, - ).not.toBe(true) - expect( - (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, - ).not.toBe(true) - expect(savedCount()).toEqual({ count: 1 }) + }); + expect(replaced.isError).not.toBe(true); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(savedCount()).toEqual({ count: 0 }); + expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true); + expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true); + expect(savedCount()).toEqual({ count: 1 }); const other = await browser.newContext({ storageState: await page.context().storageState(), baseURL, - extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, - }) + extraHTTPHeaders: { "Tailscale-User-Login": "owner@twitter-lite.invalid" }, + }); try { - const device = await other.newPage() - await device.goto('/deck') - await expect(device.getByRole('heading', { level: 1 })).toHaveText( - '更新した調査', - ) + const device = await other.newPage(); + await device.goto("/deck"); + await expect(device.getByRole("heading", { level: 1 })).toHaveText("更新した調査"); await expect( - device.getByText('alternate · Latest · all page 1', { exact: true }), - ).toBeVisible() + device.getByText("alternate · Latest · all page 1", { exact: true }), + ).toBeVisible(); } finally { - await other.close() + await other.close(); } - await page.reload() - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - '更新した調査', - ) -}) + await page.reload(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("更新した調査"); +}); -test('requires revisions for saved mutations and rejects stale overwrites', async ({ - page, -}) => { +test("requires revisions for saved mutations and rejects stale overwrites", async ({ page }) => { const deck = decoded( - await executeTool(page, 'set_deck', { title: '保存済み', columns: [] }), - ).deck - await executeTool(page, 'save_deck', { deckId: deck.id }) + await executeTool(page, "set_deck", { title: "保存済み", columns: [] }), + ).deck; + await executeTool(page, "save_deck", { deckId: deck.id }); expect( ( - await executeTool(page, 'set_deck', { + await executeTool(page, "set_deck", { deckId: deck.id, - title: '変更', + title: "変更", columns: [], }) ).isError, - ).toBe(true) + ).toBe(true); expect( ( - await executeTool(page, 'set_deck', { + await executeTool(page, "set_deck", { deckId: deck.id, expectedRevision: 1, - title: '変更', + title: "変更", columns: [], }) ).isError, - ).not.toBe(true) + ).not.toBe(true); expect( ( - await executeTool(page, 'set_deck', { + await executeTool(page, "set_deck", { deckId: deck.id, expectedRevision: 1, - title: '古い変更', + title: "古い変更", columns: [], }) ).isError, - ).toBe(true) - await expect(page.getByRole('heading', { level: 1 })).toHaveText('変更') + ).toBe(true); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("変更"); expect( ( - await executeTool(page, 'delete_deck', { + await executeTool(page, "delete_deck", { deckId: deck.id, expectedRevision: 1, }) ).isError, - ).toBe(true) + ).toBe(true); expect( ( - await executeTool(page, 'delete_deck', { + await executeTool(page, "delete_deck", { deckId: deck.id, expectedRevision: 2, }) ).isError, - ).not.toBe(true) - expect(savedCount()).toEqual({ count: 0 }) -}) + ).not.toBe(true); + expect(savedCount()).toEqual({ count: 0 }); +}); -test('keeps temporary views independent and loses only unsaved views on reload', async ({ +test("keeps temporary views independent and loses only unsaved views on reload", async ({ page, }) => { const first = decoded( - await executeTool(page, 'set_deck', { title: '一時の調査A', columns: [] }), - ).deck + await executeTool(page, "set_deck", { title: "一時の調査A", columns: [] }), + ).deck; const second = decoded( - await executeTool(page, 'set_deck', { title: '一時の調査B', columns: [] }), - ).deck - await executeTool(page, 'select_deck', { deckId: first.id }) - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - '一時の調査A', - ) - await executeTool(page, 'delete_deck', { deckId: second.id }) + await executeTool(page, "set_deck", { title: "一時の調査B", columns: [] }), + ).deck; + await executeTool(page, "select_deck", { deckId: first.id }); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("一時の調査A"); + await executeTool(page, "delete_deck", { deckId: second.id }); expect( - decoded(await executeTool(page, 'list_decks')).decks.some( + decoded(await executeTool(page, "list_decks")).decks.some( (deck: { id: string }) => deck.id === second.id, ), - ).toBe(false) - expect(savedCount()).toEqual({ count: 0 }) - await page.reload() - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - 'New research', - ) -}) + ).toBe(false); + expect(savedCount()).toEqual({ count: 0 }); + await page.reload(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("New research"); +}); -test('rejects invalid input without changing the active view', async ({ - page, -}) => { - await executeTool(page, 'set_deck', { - title: '残す調査', - columns: [column('残す観点', accounts.e2e ?? '')], - }) - const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' } +test("rejects invalid input without changing the active view", async ({ page }) => { + await executeTool(page, "set_deck", { + title: "残す調査", + columns: [column("残す観点", accounts.e2e ?? "")], + }); + const duplicate = { ...column("不正", accounts.e2e ?? ""), id: "duplicate" }; for (const columns of [ [duplicate, duplicate], - [column('不明', 'missing')], + [column("不明", "missing")], [ { - ...column('不正', accounts.e2e ?? ''), - source: { kind: 'list', target: 'invalid' }, + ...column("不正", accounts.e2e ?? ""), + source: { kind: "list", target: "invalid" }, }, ], ]) { - expect( - (await executeTool(page, 'set_deck', { title: '変更しない', columns })) - .isError, - ).toBe(true) - await expect(page.locator('.deck-column h2')).toHaveText(['残す観点']) + expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe( + true, + ); + await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]); } - expect(savedCount()).toEqual({ count: 0 }) -}) + expect(savedCount()).toEqual({ count: 0 }); +}); -test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({ +test("keeps failed temporary saves visible and allows retry without duplicate decks", async ({ page, }) => { const deck = decoded( - await executeTool(page, 'set_deck', { title: '未保存の調査', columns: [] }), - ).deck - await page.route('**/_serverFn/**', (route) => - route.request().method() === 'POST' ? route.abort() : route.continue(), - ) - expect( - (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, - ).toBe(true) - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - '未保存の調査', - ) - expect(decoded(await executeTool(page, 'get_deck')).deck.persisted).toBe( - false, - ) - await page.unroute('**/_serverFn/**') - expect( - (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, - ).not.toBe(true) - expect(savedCount()).toEqual({ count: 1 }) -}) + await executeTool(page, "set_deck", { title: "未保存の調査", columns: [] }), + ).deck; + await page.route("**/_serverFn/**", (route) => + route.request().method() === "POST" ? route.abort() : route.continue(), + ); + expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).toBe(true); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("未保存の調査"); + expect(decoded(await executeTool(page, "get_deck")).deck.persisted).toBe(false); + await page.unroute("**/_serverFn/**"); + expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true); + expect(savedCount()).toEqual({ count: 1 }); +}); -test('reads and pages bound accounts without mixing sibling columns', async ({ - page, -}) => { +test("reads and pages bound accounts without mixing sibling columns", async ({ page }) => { const deck = decoded( - await executeTool(page, 'set_deck', { - title: '接続を比較', - columns: [ - column('通常', accounts.e2e ?? ''), - column('別の接続', accounts.alternate ?? ''), - ], + await executeTool(page, "set_deck", { + title: "接続を比較", + columns: [column("通常", accounts.e2e ?? ""), column("別の接続", accounts.alternate ?? "")], }), - ).deck - await expect( - page.getByText('alternate · Latest · all page 1', { exact: true }), - ).toBeVisible() - const id = deck.columns[1].id - const loaded = decoded( - await executeTool(page, 'get_column_posts', { columnId: id }), - ) - expect(loaded.column.connectionId).toBe(accounts.alternate) + ).deck; + await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible(); + const id = deck.columns[1].id; + const loaded = decoded(await executeTool(page, "get_column_posts", { columnId: id })); + expect(loaded.column.connectionId).toBe(accounts.alternate); expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([ - 'alternate · Latest · all page 1', - ]) - const more = decoded( - await executeTool(page, 'load_more_column', { columnId: id }), - ) - expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) + "alternate · Latest · all page 1", + ]); + const more = decoded(await executeTool(page, "load_more_column", { columnId: id })); + expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }); expect(more.posts.map((post: { text: string }) => post.text)).toEqual([ - 'alternate · Latest · all page 2', - ]) + "alternate · Latest · all page 2", + ]); await expect( - page.getByRole('region', { name: '通常', exact: true }).locator('article'), - ).toHaveCount(1) - await executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] }) - expect( - (await executeTool(page, 'get_column_posts', { columnId: id })).isError, - ).toBe(true) -}) + page.getByRole("region", { name: "通常", exact: true }).locator("article"), + ).toHaveCount(1); + await executeTool(page, "set_deck", { title: "別の一時ビュー", columns: [] }); + expect((await executeTool(page, "get_column_posts", { columnId: id })).isError).toBe(true); +}); diff --git a/e2e/integrations/workspace.test.ts b/e2e/integrations/workspace.test.ts index 11bc7b8..01eda00 100644 --- a/e2e/integrations/workspace.test.ts +++ b/e2e/integrations/workspace.test.ts @@ -1,41 +1,35 @@ -import { expect, test } from '../fixtures' -import { closeMobileSidebar, showResearch } from '../workspace' +import { expect, test } from "../fixtures"; +import { closeMobileSidebar, showResearch } from "../workspace"; -test('keeps the sidebar, composer and deck reachable at compact widths', async ({ +test("keeps the sidebar, composer and deck reachable at compact widths", async ({ page, a11y, }, testInfo) => { - await page.goto('/deck') + await page.goto("/deck"); for (const width of [320, 375, 414, 768]) { - await page.setViewportSize({ width, height: 850 }) - await showResearch(page) - const chat = page.getByRole('complementary', { name: 'Research chat' }) - await expect(chat).toBeVisible() - const composer = chat.getByRole('textbox', { - name: 'Message', + await page.setViewportSize({ width, height: 850 }); + await showResearch(page); + const chat = page.getByRole("complementary", { name: "Research chat" }); + await expect(chat).toBeVisible(); + const composer = chat.getByRole("textbox", { + name: "Message", exact: true, - }) - await expect(composer).toBeVisible() - const bounds = await composer.boundingBox() - if (!bounds) throw new Error('Composer must have a visible bounding box') - expect(bounds.x).toBeGreaterThanOrEqual(0) - expect(bounds.x + bounds.width).toBeLessThanOrEqual(width) - expect(bounds.y + bounds.height).toBeLessThanOrEqual(850) - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width) + }); + await expect(composer).toBeVisible(); + const bounds = await composer.boundingBox(); + if (!bounds) throw new Error("Composer must have a visible bounding box"); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(width); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(850); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); await page.screenshot({ path: testInfo.outputPath(`workspace-${width}.png`), - }) - if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) - await closeMobileSidebar(page) + }); + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); + await closeMobileSidebar(page); } - await page.getByRole('button', { name: 'Toggle sidebar' }).click() - await expect( - page.getByRole('complementary', { name: 'Research chat' }), - ).toBeHidden() - await page.getByRole('button', { name: 'Research chat', exact: true }).click() - await expect( - page.getByRole('complementary', { name: 'Research chat' }), - ).toBeVisible() -}) + await page.getByRole("button", { name: "Toggle sidebar" }).click(); + await expect(page.getByRole("complementary", { name: "Research chat" })).toBeHidden(); + await page.getByRole("button", { name: "Research chat", exact: true }).click(); + await expect(page.getByRole("complementary", { name: "Research chat" })).toBeVisible(); +}); diff --git a/e2e/mock-relay.mjs b/e2e/mock-relay.mjs index 65f5383..c51b441 100644 --- a/e2e/mock-relay.mjs +++ b/e2e/mock-relay.mjs @@ -1,33 +1,32 @@ -import { createServer } from 'node:http' -import { tweetLegacyFixture } from './fixtures/twitter.ts' +import { createServer } from "node:http"; +import { tweetLegacyFixture } from "./fixtures/twitter.ts"; -const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT -const port = Number(portValue) +const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT; +const port = Number(portValue); if (!portValue || !Number.isInteger(port) || port < 1) { - throw new Error('TWITTER_LITE_MOCK_RELAY_PORT must be a valid port') + throw new Error("TWITTER_LITE_MOCK_RELAY_PORT must be a valid port"); } -const failedRetryQueries = new Set() +const failedRetryQueries = new Set(); const rootByTarget = new Map([ - ['1001', '1000'], - ['2001', '2000'], - ['3001', '3000'], -]) + ["1001", "1000"], + ["2001", "2000"], + ["3001", "3000"], +]); const targetByRoot = new Map([ - ['1000', '1001'], - ['2000', '2001'], - ['3000', '3001'], -]) -const failedThreadRoots = new Set() + ["1000", "1001"], + ["2000", "2001"], + ["3000", "3001"], +]); +const failedThreadRoots = new Set(); -const isRecord = (value) => - value !== null && typeof value === 'object' && !Array.isArray(value) +const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value); const tweetResult = ( id, text, - username = 'yuta', + username = "yuta", { conversationId = id, inReplyTo, replyCount = 1 } = {}, ) => ({ rest_id: id, @@ -39,14 +38,14 @@ const tweetResult = ( core: { user_results: { result: { - rest_id: '42', - legacy: { screen_name: username, name: 'Yuta' }, + rest_id: "42", + legacy: { screen_name: username, name: "Yuta" }, }, }, }, -}) +}); -const tweet = (id, text, username = 'yuta', options) => ({ +const tweet = (id, text, username = "yuta", options) => ({ entryId: `tweet-${id}`, content: { itemContent: { @@ -55,149 +54,140 @@ const tweet = (id, text, username = 'yuta', options) => ({ }, }, }, -}) +}); const cursor = (value) => ({ - entryId: 'cursor-bottom', - content: { cursorType: 'Bottom', value }, -}) + entryId: "cursor-bottom", + content: { cursorType: "Bottom", value }, +}); const send = (response, payload, status = 200, headers = {}) => { response.writeHead(status, { - 'content-type': 'application/json; charset=utf-8', + "content-type": "application/json; charset=utf-8", ...headers, - }) - response.end(JSON.stringify(payload)) -} + }); + response.end(JSON.stringify(payload)); +}; const fail = (response, status, message, headers) => - send(response, { errors: [{ message }] }, status, headers) + send(response, { errors: [{ message }] }, status, headers); const variablesFrom = (url) => { - const encoded = url.searchParams.get('variables') - if (!encoded) throw new Error('variables are required') - const variables = JSON.parse(encoded) - if (!isRecord(variables)) throw new Error('variables must be an object') - return variables -} + const encoded = url.searchParams.get("variables"); + if (!encoded) throw new Error("variables are required"); + const variables = JSON.parse(encoded); + if (!isRecord(variables)) throw new Error("variables must be an object"); + return variables; +}; const jsonBodyFrom = async (request) => { - let body = '' + let body = ""; for await (const chunk of request) { - body += chunk - if (body.length > 1_000_000) throw new Error('request body is too large') + body += chunk; + if (body.length > 1_000_000) throw new Error("request body is too large"); } - const parsed = JSON.parse(body) - if (!isRecord(parsed)) throw new Error('JSON body must be an object') - return parsed -} + const parsed = JSON.parse(body); + if (!isRecord(parsed)) throw new Error("JSON body must be an object"); + return parsed; +}; -const timeline = (entries) => ({ instructions: [{ entries }] }) +const timeline = (entries) => ({ instructions: [{ entries }] }); const server = createServer(async (request, response) => { - const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`) - if (request.method === 'GET' && url.pathname === '/profiles') { - send(response, { profiles: ['e2e', 'alternate'] }) - return + const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); + if (request.method === "GET" && url.pathname === "/profiles") { + send(response, { profiles: ["e2e", "alternate"] }); + return; } const operation = [ - 'UserByScreenName', - 'UserTweets', - 'TweetDetail', - 'SearchTimeline', - 'ListsManagementPageTimeline', - 'ListLatestTweetsTimeline', - ].find((name) => url.pathname.endsWith(`/${name}`)) + "UserByScreenName", + "UserTweets", + "TweetDetail", + "SearchTimeline", + "ListsManagementPageTimeline", + "ListLatestTweetsTimeline", + ].find((name) => url.pathname.endsWith(`/${name}`)); if (!operation) { - fail(response, 501, 'unsupported read operation') - return + fail(response, 501, "unsupported read operation"); + return; } - const expectedMethod = operation === 'SearchTimeline' ? 'POST' : 'GET' + const expectedMethod = operation === "SearchTimeline" ? "POST" : "GET"; if (request.method !== expectedMethod) { fail(response, 405, `expected ${expectedMethod}`, { allow: expectedMethod, - }) - return + }); + return; } - const profileName = request.headers['x-profile-name'] - if (profileName !== 'e2e' && profileName !== 'alternate') { - fail(response, 403, 'invalid profile') - return + const profileName = request.headers["x-profile-name"]; + if (profileName !== "e2e" && profileName !== "alternate") { + fail(response, 403, "invalid profile"); + return; } - let variables + let variables; try { - variables = variablesFrom(url) + variables = variablesFrom(url); } catch { - fail(response, 400, 'malformed variables') - return + fail(response, 400, "malformed variables"); + return; } - if (operation === 'UserByScreenName') { - if ( - typeof variables.screen_name !== 'string' || - variables.screen_name.length === 0 - ) { - fail(response, 400, 'screen_name is required') - return + if (operation === "UserByScreenName") { + if (typeof variables.screen_name !== "string" || variables.screen_name.length === 0) { + fail(response, 400, "screen_name is required"); + return; } send(response, { data: { user: { result: { - rest_id: '42', - legacy: { screen_name: variables.screen_name, name: 'Yuta' }, + rest_id: "42", + legacy: { screen_name: variables.screen_name, name: "Yuta" }, }, }, }, - }) - return + }); + return; } - if (operation === 'UserTweets') { - const expectedCursor = - variables.cursor === undefined || variables.cursor === 'user-next' - if ( - variables.userId !== '42' || - variables.count !== 20 || - !expectedCursor - ) { - fail(response, 400, 'invalid user timeline variables') - return + if (operation === "UserTweets") { + const expectedCursor = variables.cursor === undefined || variables.cursor === "user-next"; + if (variables.userId !== "42" || variables.count !== 20 || !expectedCursor) { + fail(response, 400, "invalid user timeline variables"); + return; } - const secondPage = variables.cursor === 'user-next' - const firstPageText = - profileName === 'alternate' ? 'alternate profile page 1' : 'user page 1' + const secondPage = variables.cursor === "user-next"; + const firstPageText = profileName === "alternate" ? "alternate profile page 1" : "user page 1"; const entries = secondPage - ? [tweet('1010', 'user page 2')] + ? [tweet("1010", "user page 2")] : [ - tweet('1001', firstPageText, 'yuta', { - conversationId: '1000', + tweet("1001", firstPageText, "yuta", { + conversationId: "1000", }), - cursor('user-next'), - ] + cursor("user-next"), + ]; send(response, { data: { user: { result: { timeline: { timeline: timeline(entries) } } }, }, - }) - return + }); + return; } - if (operation === 'ListsManagementPageTimeline') { + if (operation === "ListsManagementPageTimeline") { if (variables.count !== 100) { - fail(response, 400, 'invalid lists management variables') - return + fail(response, 400, "invalid lists management variables"); + return; } - const features = url.searchParams.get('features') + const features = url.searchParams.get("features"); try { - if (!features || !isRecord(JSON.parse(features))) throw new Error() + if (!features || !isRecord(JSON.parse(features))) throw new Error(); } catch { - fail(response, 400, 'invalid lists management feature lock') - return + fail(response, 400, "invalid lists management feature lock"); + return; } send(response, { data: { @@ -210,28 +200,26 @@ const server = createServer(async (request, response) => { { content: { clientEventInfo: { - component: 'suggest_owned_subscribed_list', + component: "suggest_owned_subscribed_list", }, items: [ { item: { itemContent: { list: { - id_str: '1234567890', + id_str: "1234567890", name: - profileName === 'alternate' - ? 'Alternate List' - : 'My E2E List', - description: 'Accounts selected for testing', + profileName === "alternate" ? "Alternate List" : "My E2E List", + description: "Accounts selected for testing", member_count: 10, - mode: 'Public', + mode: "Public", created_at: 1_704_067_200_000, user_results: { result: { - rest_id: '42', + rest_id: "42", core: { - screen_name: 'yuta', - name: 'Yuta', + screen_name: "yuta", + name: "Yuta", }, }, }, @@ -249,66 +237,65 @@ const server = createServer(async (request, response) => { }, }, }, - errors: [{ message: 'optional banner field failed' }], - }) - return + errors: [{ message: "optional banner field failed" }], + }); + return; } - if (operation === 'ListLatestTweetsTimeline') { - const expectedCursor = - variables.cursor === undefined || variables.cursor === 'list-next' + if (operation === "ListLatestTweetsTimeline") { + const expectedCursor = variables.cursor === undefined || variables.cursor === "list-next"; if ( - typeof variables.listId !== 'string' || + typeof variables.listId !== "string" || !/^\d+$/.test(variables.listId) || variables.count !== 20 || !expectedCursor ) { - fail(response, 400, 'invalid list timeline variables') - return + fail(response, 400, "invalid list timeline variables"); + return; } - const secondPage = variables.cursor === 'list-next' + const secondPage = variables.cursor === "list-next"; const entries = secondPage - ? [tweet('3010', 'list page 2', 'listed')] - : [tweet('3001', 'list page 1', 'listed'), cursor('list-next')] + ? [tweet("3010", "list page 2", "listed")] + : [tweet("3001", "list page 1", "listed"), cursor("list-next")]; send(response, { data: { list: { tweets_timeline: { timeline: timeline(entries) } }, }, - }) - return + }); + return; } - if (operation === 'TweetDetail') { - const focalTweetId = variables.focalTweetId - if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) { - fail(response, 400, 'invalid focalTweetId') - return + if (operation === "TweetDetail") { + const focalTweetId = variables.focalTweetId; + if (typeof focalTweetId !== "string" || !/^\d+$/.test(focalTweetId)) { + fail(response, 400, "invalid focalTweetId"); + return; } - const features = url.searchParams.get('features') - const fieldToggles = url.searchParams.get('fieldToggles') + const features = url.searchParams.get("features"); + const fieldToggles = url.searchParams.get("fieldToggles"); try { - if (!features || !isRecord(JSON.parse(features))) throw new Error() + if (!features || !isRecord(JSON.parse(features))) throw new Error(); if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) { - throw new Error() + throw new Error(); } } catch { - fail(response, 400, 'invalid TweetDetail feature locks') - return + fail(response, 400, "invalid TweetDetail feature locks"); + return; } - const targetRoot = rootByTarget.get(focalTweetId) + const targetRoot = rootByTarget.get(focalTweetId); if (targetRoot && variables.cursor === undefined) { - const noReplies = focalTweetId === '3001' + const noReplies = focalTweetId === "3001"; const text = noReplies - ? 'no replies' - : focalTweetId === '1001' - ? 'user page 1' - : 'retry focal' - const result = tweetResult(focalTweetId, text, 'focus', { + ? "no replies" + : focalTweetId === "1001" + ? "user page 1" + : "retry focal"; + const result = tweetResult(focalTweetId, text, "focus", { conversationId: targetRoot, inReplyTo: targetRoot, replyCount: noReplies ? 0 : 1, - }) + }); send(response, { data: { tweetResult: { result }, @@ -316,7 +303,7 @@ const server = createServer(async (request, response) => { instructions: [ { entries: [ - tweet(focalTweetId, text, 'focus', { + tweet(focalTweetId, text, "focus", { conversationId: targetRoot, inReplyTo: targetRoot, replyCount: noReplies ? 0 : 1, @@ -326,47 +313,43 @@ const server = createServer(async (request, response) => { ], }, }, - }) - return + }); + return; } - const targetId = targetByRoot.get(focalTweetId) + const targetId = targetByRoot.get(focalTweetId); if (!targetId) { - fail(response, 400, 'unsupported TweetDetail fixture') - return + fail(response, 400, "unsupported TweetDetail fixture"); + return; } - const expectedCursor = `thread-next:${focalTweetId}` + const expectedCursor = `thread-next:${focalTweetId}`; if (variables.cursor !== undefined && variables.cursor !== expectedCursor) { - fail(response, 400, 'invalid thread cursor') - return + fail(response, 400, "invalid thread cursor"); + return; } - if (variables.cursor && focalTweetId === '3000') { - fail(response, 500, 'zero-reply thread must not request a continuation') - return + if (variables.cursor && focalTweetId === "3000") { + fail(response, 500, "zero-reply thread must not request a continuation"); + return; } - if ( - variables.cursor && - focalTweetId === '2000' && - !failedThreadRoots.has(focalTweetId) - ) { - failedThreadRoots.add(focalTweetId) - fail(response, 503, 'transient thread fixture failure') - return + if (variables.cursor && focalTweetId === "2000" && !failedThreadRoots.has(focalTweetId)) { + failedThreadRoots.add(focalTweetId); + fail(response, 503, "transient thread fixture failure"); + return; } const entries = variables.cursor ? [ - tweet(`${focalTweetId}3`, 'thread page 2', 'reply2', { + tweet(`${focalTweetId}3`, "thread page 2", "reply2", { conversationId: focalTweetId, inReplyTo: targetId, }), ] - : focalTweetId === '3000' + : focalTweetId === "3000" ? [ - tweet(focalTweetId, 'thread root', 'root', { + tweet(focalTweetId, "thread root", "root", { conversationId: focalTweetId, }), - tweet(targetId, 'no replies', 'focus', { + tweet(targetId, "no replies", "focus", { conversationId: focalTweetId, inReplyTo: focalTweetId, replyCount: 0, @@ -374,28 +357,23 @@ const server = createServer(async (request, response) => { cursor(expectedCursor), ] : [ - tweet(focalTweetId, 'thread root', 'root', { + tweet(focalTweetId, "thread root", "root", { conversationId: focalTweetId, }), - tweet( - targetId, - focalTweetId === '1000' ? 'user page 1' : 'retry focal', - 'focus', - { - conversationId: focalTweetId, - inReplyTo: focalTweetId, - }, - ), - tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', { + tweet(targetId, focalTweetId === "1000" ? "user page 1" : "retry focal", "focus", { + conversationId: focalTweetId, + inReplyTo: focalTweetId, + }), + tweet(`${focalTweetId}2`, "thread page 1", "reply1", { conversationId: focalTweetId, inReplyTo: targetId, }), cursor(expectedCursor), - ] + ]; send(response, { data: { tweetResult: { - result: tweetResult(focalTweetId, 'thread root', 'root', { + result: tweetResult(focalTweetId, "thread root", "root", { conversationId: focalTweetId, }), }, @@ -403,67 +381,62 @@ const server = createServer(async (request, response) => { instructions: [{ entries }], }, }, - }) - if (variables.cursor && focalTweetId === '2000') { - failedThreadRoots.delete(focalTweetId) + }); + if (variables.cursor && focalTweetId === "2000") { + failedThreadRoots.delete(focalTweetId); } - return + return; } - const contentType = request.headers['content-type'] ?? '' + const contentType = request.headers["content-type"] ?? ""; if (!/^application\/json(?:\s*;|$)/i.test(contentType)) { - fail(response, 400, 'SearchTimeline requires JSON') - return + fail(response, 400, "SearchTimeline requires JSON"); + return; } - let body + let body; try { - body = await jsonBodyFrom(request) + body = await jsonBodyFrom(request); } catch { - fail(response, 400, 'malformed JSON body') - return + fail(response, 400, "malformed JSON body"); + return; } - const rawQuery = variables.rawQuery + const rawQuery = variables.rawQuery; const expectedCursor = - variables.cursor === undefined || - variables.cursor === `search-next:${rawQuery}` + variables.cursor === undefined || variables.cursor === `search-next:${rawQuery}`; if ( - typeof rawQuery !== 'string' || + typeof rawQuery !== "string" || rawQuery.length === 0 || variables.count !== 20 || - variables.querySource !== 'typed_query' || - !['Top', 'Latest'].includes(variables.product) || + variables.querySource !== "typed_query" || + !["Top", "Latest"].includes(variables.product) || !expectedCursor || !isRecord(body.features) || - typeof body.queryId !== 'string' || + typeof body.queryId !== "string" || body.queryId.length === 0 || - Object.hasOwn(body, 'variables') + Object.hasOwn(body, "variables") ) { - fail(response, 400, 'invalid search request') - return + fail(response, 400, "invalid search request"); + return; } - const secondPage = variables.cursor !== undefined - if ( - secondPage && - rawQuery.startsWith('retry-') && - !failedRetryQueries.has(rawQuery) - ) { - failedRetryQueries.add(rawQuery) - fail(response, 503, 'transient fixture failure') - return + const secondPage = variables.cursor !== undefined; + if (secondPage && rawQuery.startsWith("retry-") && !failedRetryQueries.has(rawQuery)) { + failedRetryQueries.add(rawQuery); + fail(response, 503, "transient fixture failure"); + return; } - if (secondPage && rawQuery.startsWith('slow-')) { - await new Promise((resolve) => setTimeout(resolve, 1_200)) + if (secondPage && rawQuery.startsWith("slow-")) { + await new Promise((resolve) => setTimeout(resolve, 1_200)); } - const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all' - const label = `${profileName === 'alternate' ? 'alternate · ' : ''}${variables.product} · ${follows}` - const id = encodeURIComponent(rawQuery) + const follows = rawQuery.includes("filter:follows") ? "follows" : "all"; + const label = `${profileName === "alternate" ? "alternate · " : ""}${variables.product} · ${follows}`; + const id = encodeURIComponent(rawQuery); const entries = secondPage ? [tweet(`s-${id}-2`, `${label} page 2`)] - : [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)] + : [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)]; send(response, { data: { @@ -471,7 +444,7 @@ const server = createServer(async (request, response) => { search_timeline: { timeline: timeline(entries) }, }, }, - }) -}) + }); +}); -server.listen(port, '127.0.0.1') +server.listen(port, "127.0.0.1"); diff --git a/e2e/workspace.ts b/e2e/workspace.ts index 1a4b516..14f400e 100644 --- a/e2e/workspace.ts +++ b/e2e/workspace.ts @@ -1,39 +1,33 @@ -import { expect, type Page } from '@playwright/test' +import { expect, type Page } from "@playwright/test"; export async function showDeckManagement(page: Page) { - await expect( - page.getByRole('button', { name: 'Rename', includeHidden: true }), - ).toBeEnabled() - const navigation = page.getByRole('button', { - name: 'Deck manager', + await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); + const navigation = page.getByRole("button", { + name: "Deck manager", exact: true, - }) + }); if (!(await navigation.isVisible())) { - await page.getByRole('button', { name: 'Toggle sidebar' }).click() + await page.getByRole("button", { name: "Toggle sidebar" }).click(); } - await navigation.click() + await navigation.click(); } export async function showResearch(page: Page) { - await expect( - page.getByRole('button', { name: 'Rename', includeHidden: true }), - ).toBeEnabled() - const navigation = page.getByRole('button', { - name: 'Research chat', + await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); + const navigation = page.getByRole("button", { + name: "Research chat", exact: true, - }) + }); if (!(await navigation.isVisible())) { - await page.getByRole('button', { name: 'Toggle sidebar' }).click() + await page.getByRole("button", { name: "Toggle sidebar" }).click(); } - await navigation.click() + await navigation.click(); } export async function closeMobileSidebar(page: Page) { - const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]') + const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]'); if (await sheet.isVisible()) { - await page - .getByRole('button', { name: 'Close sidebar', exact: true }) - .click() - await expect(sheet).toBeHidden() + await page.getByRole("button", { name: "Close sidebar", exact: true }).click(); + await expect(sheet).toBeHidden(); } } diff --git a/package.json b/package.json index f585b84..61340e3 100644 --- a/package.json +++ b/package.json @@ -2,56 +2,41 @@ "name": "twitter-lite", "private": true, "type": "module", - "packageManager": "pnpm@11.9.0", - "engines": { - "node": ">=22.12.0" - }, - "knip": { - "ignoreBinaries": [ - "codex" - ], - "ignore": [ - "e2e/generated/**" - ], - "ignoreIssues": { - "src/components/ui/**": [ - "exports" - ] - } - }, "imports": { "#/*": "./src/*" }, "scripts": { - "codex:serve": "node scripts/serve-codex.mjs", - "db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && biome format --write src/features/storage/migrations.generated.ts", + "db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && oxfmt --write src/features/storage/migrations.generated.ts", "db:backup": "tsx scripts/backup-database.ts", "dev": "vite dev --host 127.0.0.1 --port 3000", "dev:tailscale": "vite dev --host 127.0.0.1 --port 3000", - "generate:e2e-openapi": "orval --config orval.config.ts --formatter biome --fail-on-warnings", + "generate:e2e-openapi": "orval --config orval.config.ts --fail-on-warnings && oxfmt --write e2e/generated", "generate-routes": "node scripts/generate-routes.mjs", "check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts", "build": "vite build && node scripts/build-tools.mjs", "start": "HOST=127.0.0.1 node .output/server/index.mjs", "typecheck": "tsc --noEmit", - "lint": "biome check . && pnpm lint:ui", + "lint": "oxlint", "knip": "knip", - "format": "biome check --write .", + "format": "oxfmt --write .", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", "test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts", - "lint:ui": "oxlint src", - "account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.ts" + "account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.ts", + "format:check": "oxfmt --check ." }, "dependencies": { "@base-ui/react": "1.8.0", "@shadcn/react": "^0.3.1", + "@tanstack/react-pacer": "^0.23.0", "@tanstack/react-query": "5.101.2", "@tanstack/react-router": "1.170.17", "@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-start": "1.168.27", "@yuta/bird": "0.10.1", + "ai": "7.0.118", + "ai-sdk-provider-codex-cli": "2.3.0", "better-sqlite3": "13.0.3", "class-variance-authority": "0.7.1", "cn": "^0.4.0", @@ -60,18 +45,18 @@ "nitro": "3.0.260610-beta", "react": "19.2.7", "react-dom": "19.2.7", - "react-markdown": "10.1.0", "remark-gfm": "4.0.1", "sanitize-html": "2.17.7", "shadcn": "4.21.0", + "streamdown": "^2.6.0", "tw-animate-css": "1.4.0", "usewebmcp": "5.1.0", "zod": "4.4.3" }, "devDependencies": { "@axe-core/playwright": "4.11.1", - "@biomejs/biome": "2.5.3", "@playwright/test": "1.61.1", + "@shadcn/helpers": "0.2.0", "@shadcn/lint": "^0.2.0", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "10.4.1", @@ -87,11 +72,27 @@ "jsdom": "29.1.1", "knip": "6.27.0", "orval": "8.24.0", + "oxfmt": "^0.70.0", "oxlint": "^1.85.0", + "oxlint-tsgolint": "^7.0.2003", "tailwindcss": "^4.3.3", "tsx": "4.23.12", "typescript": "7.0.2", "vite": "8.1.4", "vitest": "4.1.10" + }, + "engines": { + "node": ">=22.12.0" + }, + "packageManager": "pnpm@11.9.0", + "knip": { + "ignore": [ + "e2e/generated/**" + ], + "ignoreIssues": { + "src/components/ui/**": [ + "exports" + ] + } } } diff --git a/playwright.config.ts b/playwright.config.ts index a611b22..97fa90d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,32 +1,32 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { defineConfig, devices } from '@playwright/test' +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defineConfig, devices } from "@playwright/test"; -const appPort = 4173 -const relayPort = 6911 -const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE +const appPort = 4173; +const relayPort = 6911; +const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE; const databasePath = process.env.TWITTER_LITE_E2E_DB_PATH ?? - join(mkdtempSync(join(tmpdir(), 'twitter-lite-e2e-')), 'workspace.sqlite') -process.env.TWITTER_LITE_E2E_DB_PATH = databasePath + join(mkdtempSync(join(tmpdir(), "twitter-lite-e2e-")), "workspace.sqlite"); +process.env.TWITTER_LITE_E2E_DB_PATH = databasePath; if (!chromiumExecutable) { throw new Error( - 'PLAYWRIGHT_CHROMIUM_EXECUTABLE is required; run E2E tests with `nix develop -c pnpm test:e2e`.', - ) + "PLAYWRIGHT_CHROMIUM_EXECUTABLE is required; run E2E tests with `nix develop -c pnpm test:e2e`.", + ); } export default defineConfig({ - testDir: './e2e', + testDir: "./e2e", fullyParallel: false, workers: 1, use: { baseURL: `http://127.0.0.1:${appPort}`, - extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, + extraHTTPHeaders: { "Tailscale-User-Login": "owner@twitter-lite.invalid" }, launchOptions: { executablePath: chromiumExecutable }, - screenshot: 'only-on-failure', - trace: 'retain-on-failure', + screenshot: "only-on-failure", + trace: "retain-on-failure", }, webServer: [ { @@ -36,14 +36,14 @@ export default defineConfig({ timeout: 120_000, }, { - command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} TWITTER_LITE_ALLOWED_LOGIN=owner@twitter-lite.invalid TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, + command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_PATH= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} TWITTER_LITE_ALLOWED_LOGIN=owner@twitter-lite.invalid TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, port: appPort, reuseExistingServer: false, timeout: 120_000, }, ], projects: [ - { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, - { name: 'mobile', use: { ...devices['Pixel 7'] } }, + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile", use: { ...devices["Pixel 7"] } }, ], -}) +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18a3b7..1b19ff1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@shadcn/react': specifier: ^0.3.1 version: 0.3.1(@types/react@19.2.17)(react@19.2.7) + '@tanstack/react-pacer': + specifier: ^0.23.0 + version: 0.23.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-query': specifier: 5.101.2 version: 5.101.2(react@19.2.7) @@ -29,6 +32,12 @@ importers: '@yuta/bird': specifier: 0.10.1 version: 0.10.1 + ai: + specifier: 7.0.118 + version: 7.0.118(zod@4.4.3) + ai-sdk-provider-codex-cli: + specifier: 2.3.0 + version: 2.3.0(zod@4.4.3) better-sqlite3: specifier: 13.0.3 version: 13.0.3 @@ -53,9 +62,6 @@ importers: react-dom: specifier: 19.2.7 version: 19.2.7(react@19.2.7) - react-markdown: - specifier: 10.1.0 - version: 10.1.0(@types/react@19.2.17)(react@19.2.7) remark-gfm: specifier: 4.0.1 version: 4.0.1 @@ -65,6 +71,9 @@ importers: shadcn: specifier: 4.21.0 version: 4.21.0(typescript@7.0.2) + streamdown: + specifier: ^2.6.0 + version: 2.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tw-animate-css: specifier: 1.4.0 version: 1.4.0 @@ -78,12 +87,12 @@ importers: '@axe-core/playwright': specifier: 4.11.1 version: 4.11.1(playwright-core@1.61.1) - '@biomejs/biome': - specifier: 2.5.3 - version: 2.5.3 '@playwright/test': specifier: 1.61.1 version: 1.61.1 + '@shadcn/helpers': + specifier: 0.2.0 + version: 0.2.0(ai@7.0.118(zod@4.4.3)) '@shadcn/lint': specifier: ^0.2.0 version: 0.2.0 @@ -129,9 +138,15 @@ importers: orval: specifier: 8.24.0 version: 8.24.0(@faker-js/faker@10.5.0)(prettier@3.9.5)(typescript@7.0.2) + oxfmt: + specifier: ^0.70.0 + version: 0.70.0 oxlint: specifier: ^1.85.0 - version: 1.85.0 + version: 1.85.0(oxlint-tsgolint@7.0.2003) + oxlint-tsgolint: + specifier: ^7.0.2003 + version: 7.0.2003 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -153,6 +168,22 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ai-sdk/gateway@4.0.96': + resolution: {integrity: sha512-1aniRTzEf9T7uRHdKnBQ36WgEVQswK6wDgkPVsNMl+sPy7otcmrBa2HEurdFBS51s1q3/UXlh8nNcguwU79Hkg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.49': + resolution: {integrity: sha512-T+/H8DCvqJoCqLhltVatS7ffA779Iyk/ZZrgNXkrgv51E0n2Cd6eIdCmtwhCGIKqtbTtwCvTmQdAMv5Z8rOGHQ==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.18': + resolution: {integrity: sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==} + engines: {node: '>=22'} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -337,63 +368,6 @@ packages: '@types/react': optional: true - '@biomejs/biome@2.5.3': - resolution: {integrity: sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@2.5.3': - resolution: {integrity: sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@2.5.3': - resolution: {integrity: sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@2.5.3': - resolution: {integrity: sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-arm64@2.5.3': - resolution: {integrity: sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-linux-x64-musl@2.5.3': - resolution: {integrity: sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-x64@2.5.3': - resolution: {integrity: sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-win32-arm64@2.5.3': - resolution: {integrity: sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@2.5.3': - resolution: {integrity: sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -1033,6 +1007,47 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} + '@openai/codex@0.156.1': + resolution: {integrity: sha512-nI1iVl/n2SO2lSvlwEsJx63zdSI4C4Me2gR7AG0OWMJiGSakz2tY2hx43E39Zq5aEoeB5bZjJXzp5Sqhog6vyA==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.156.1-darwin-arm64': + resolution: {integrity: sha512-Jg6wbdV+wmMZczhwE74GSxOYEZlViKXn6KyCw/yfrz3PAKFD14xljuPopmdhWC1+8IKU2WdN5fdmXNPt2q4HPA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.156.1-darwin-x64': + resolution: {integrity: sha512-BVjqNOoltWrnNUrgMRepvDIIBmd4XY+ikAE4pYVHlrwiizNlEWQuCQOTrFMJKp54LFRG8jDTjLZyYQ1QrliuOg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.156.1-linux-arm64': + resolution: {integrity: sha512-sTEHk31C12dqzJdjZ2u9KLFCXKL04KyCM+QheSjdQI8uzmVJS7aEF2/qAqFxXwk/jwMzC9u7IihfoIlAGR6YsA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.156.1-linux-x64': + resolution: {integrity: sha512-2ePo0wgOcnONKsuzp8vBjOmNY+IdsKaouaDDIdiKq9HOWNuV/GI22OeXft2A/1GaoL71ictO0/pLAsCEnQ6wew==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.156.1-win32-arm64': + resolution: {integrity: sha512-e5DsyOHlKWulTBz/neIczpcYLQDqtaLtBj2lgncOgeQw5mlAQVZHlGvKsoKVcEPUKLb8opIMJBxXi6cz3JJ4hg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.156.1-win32-x64': + resolution: {integrity: sha512-MJyLxbBs2zzp5kbaR/99Zwe7SmbrwUkveTcT+ayYlO48V0nYh0eU+h2lalBwvC7VJ/ya/bXnUtISJfJKhGCD/g==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@orval/angular@8.24.0': resolution: {integrity: sha512-tmDca47QJdB7+suSUdjki3oURBP/EHhHkvgbOmm4KPy2G0YBKHSEm/oMphE5fab4TBzMNEVoiBi5PnK7m6Hu3g==} @@ -1440,6 +1455,158 @@ packages: cpu: [x64] os: [win32] + '@oxfmt/binding-android-arm-eabi@0.70.0': + resolution: {integrity: sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.70.0': + resolution: {integrity: sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.70.0': + resolution: {integrity: sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.70.0': + resolution: {integrity: sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.70.0': + resolution: {integrity: sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + resolution: {integrity: sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + resolution: {integrity: sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + resolution: {integrity: sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.70.0': + resolution: {integrity: sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + resolution: {integrity: sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + resolution: {integrity: sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + resolution: {integrity: sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + resolution: {integrity: sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.70.0': + resolution: {integrity: sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.70.0': + resolution: {integrity: sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.70.0': + resolution: {integrity: sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + resolution: {integrity: sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + resolution: {integrity: sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.70.0': + resolution: {integrity: sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@7.0.2003': + resolution: {integrity: sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2003': + resolution: {integrity: sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2003': + resolution: {integrity: sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2003': + resolution: {integrity: sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2003': + resolution: {integrity: sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2003': + resolution: {integrity: sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.85.0': resolution: {integrity: sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1688,6 +1855,20 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shadcn/helpers@0.2.0': + resolution: {integrity: sha512-wpcZNuRgdW16jLwjvh4mbREKpVFrGY+nBM3Okgic8b04sijN5CHW1Nl/AEMajsP3INaCTNnqMCouOalZ7OHvEQ==} + peerDependencies: + '@tanstack/ai': '>=0.40.0 <0.41.0' + '@tanstack/ai-client': '>=0.20.0 <0.21.0' + ai: '>=7.0.0 <8' + peerDependenciesMeta: + '@tanstack/ai': + optional: true + '@tanstack/ai-client': + optional: true + ai: + optional: true + '@shadcn/lint@0.2.0': resolution: {integrity: sha512-DTYr427BR8cDc0J0Y++u1Uh12NAYGmdPiqgy4wKSogi7RNEhFanIcfBMPRPUvkunt9QiJUfI7B28TW39MrPCFA==} engines: {node: '>=20.19'} @@ -1827,13 +2008,29 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/devtools-event-client@0.5.0': + resolution: {integrity: sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA==} + engines: {node: '>=18'} + hasBin: true + '@tanstack/history@1.162.0': resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} + '@tanstack/pacer@0.22.0': + resolution: {integrity: sha512-Zq23KOn30tFiA6ZYWihs5x++ttHkOIcnIIjRrJq05tGrYx9+iHsmVaj8jb7UJVemEI+JRWGzJ9C+50YV4nRz0w==} + engines: {node: '>=18'} + '@tanstack/query-core@5.101.2': resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + '@tanstack/react-pacer@0.23.0': + resolution: {integrity: sha512-Ec3h+kT8kYlpGb1M8F7836q+udE/M2RmE8KLr1rhs+UDBgvxjfmyR2bIUeaDEe9Zn1YXNI986RbErXyr5fEJIg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-query@5.101.2': resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} peerDependencies: @@ -1904,6 +2101,12 @@ packages: vite: optional: true + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-store@0.9.3': resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: @@ -1978,6 +2181,9 @@ packages: resolution: {integrity: sha512-zTegxlij4BC1DbCrC6rsVlMOQVMzOuG5IllacZEkrUdhiFwMIMYpk0VWGH+d0ucx5RBkmv8e8GNX3AOVBWclfg==} engines: {node: '>=22.12.0'} + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} @@ -2193,6 +2399,10 @@ packages: '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@6.0.3': resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2235,6 +2445,9 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@yuta/bird@0.10.1': resolution: {integrity: sha512-WS6rI6P3ilwmTH6V9ANEp4pb1zxhnYAlKW1oDKsTwz7wJrh+toZ/bsAYaxbN/5+2wOdZEyWxSC5J8m8/goinBA==, tarball: https://git.yutakobayashi.com/api/packages/yuta/npm/%40yuta%2Fbird/-/0.10.1/bird-0.10.1.tgz} hasBin: true @@ -2248,6 +2461,18 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ai-sdk-provider-codex-cli@2.3.0: + resolution: {integrity: sha512-vwBHI1JzNw8fMDSAylVmk0INmF8IqaAOQeldMBg+KwZIyOmcj0cQQ2+VgJtl4H9kNARpFdarjfVwGwZRUgl9LQ==} + engines: {node: '>=22'} + peerDependencies: + zod: ^4.1.8 + + ai@7.0.118: + resolution: {integrity: sha512-ZjkbbOffH09yQAjeZ5yGXd8ePTQlFIyKH5aW0s0LOMmvaR1TSoeg5wVURwLvFry/IzzaLndmw9glTKwWRoog3Q==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -2805,6 +3030,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -3089,12 +3318,30 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hono@4.13.9: resolution: {integrity: sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==} engines: {node: '>=16.9.0'} @@ -3109,6 +3356,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -3316,11 +3566,17 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -3472,6 +3728,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3796,6 +4057,23 @@ packages: oxc-resolver@11.21.3: resolution: {integrity: sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==} + oxfmt@0.70.0: + resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@7.0.2003: + resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} + hasBin: true + oxlint@1.85.0: resolution: {integrity: sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3847,6 +4125,9 @@ packages: parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3975,12 +4256,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-markdown@10.1.0: - resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} - peerDependencies: - '@types/react': '>=18' - react: '>=18' - react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -3997,6 +4272,15 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + rehype-harden@1.1.8: + resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4013,6 +4297,9 @@ packages: resolution: {integrity: sha512-3Ki8dU1o3OVu4dwIQ2Pj+yiuP7OnEbmWAGmJ3yDRqopily5jsj8NWzPvbS89H85d6UdONKEcUnrfuHY6jN9vyw==} engines: {node: '>=18.0.0'} + remend@1.3.1: + resolution: {integrity: sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4189,6 +4476,12 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + streamdown@2.6.0: + resolution: {integrity: sha512-nQZVUn4GvB2R5SAlDNph20iKZeW+RM4gv6G1H3ACypEnmQf8PgTHZ/1Ta2OACRwQ2coK7aW5AeIAa7Ls5zk+RA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -4247,6 +4540,9 @@ packages: os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -4268,6 +4564,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -4364,6 +4664,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -4532,6 +4836,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -4638,6 +4945,9 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -4724,6 +5034,26 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@ai-sdk/gateway@4.0.96(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.18 + '@ai-sdk/provider-utils': 5.0.49(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@5.0.49(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.18 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.30.0 + zod: 4.4.3 + + '@ai-sdk/provider@4.0.18': + dependencies: + json-schema: 0.4.0 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -4966,41 +5296,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@biomejs/biome@2.5.3': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.3 - '@biomejs/cli-darwin-x64': 2.5.3 - '@biomejs/cli-linux-arm64': 2.5.3 - '@biomejs/cli-linux-arm64-musl': 2.5.3 - '@biomejs/cli-linux-x64': 2.5.3 - '@biomejs/cli-linux-x64-musl': 2.5.3 - '@biomejs/cli-win32-arm64': 2.5.3 - '@biomejs/cli-win32-x64': 2.5.3 - - '@biomejs/cli-darwin-arm64@2.5.3': - optional: true - - '@biomejs/cli-darwin-x64@2.5.3': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.5.3': - optional: true - - '@biomejs/cli-linux-arm64@2.5.3': - optional: true - - '@biomejs/cli-linux-x64-musl@2.5.3': - optional: true - - '@biomejs/cli-linux-x64@2.5.3': - optional: true - - '@biomejs/cli-win32-arm64@2.5.3': - optional: true - - '@biomejs/cli-win32-x64@2.5.3': - optional: true - '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -5455,6 +5750,34 @@ snapshots: '@oozcitak/util@10.0.0': {} + '@openai/codex@0.156.1': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.156.1-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.156.1-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.156.1-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.156.1-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.156.1-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.156.1-win32-x64' + optional: true + + '@openai/codex@0.156.1-darwin-arm64': + optional: true + + '@openai/codex@0.156.1-darwin-x64': + optional: true + + '@openai/codex@0.156.1-linux-arm64': + optional: true + + '@openai/codex@0.156.1-linux-x64': + optional: true + + '@openai/codex@0.156.1-win32-arm64': + optional: true + + '@openai/codex@0.156.1-win32-x64': + optional: true + '@orval/angular@8.24.0(@faker-js/faker@10.5.0)(typescript@7.0.2)': dependencies: '@orval/core': 8.24.0(@faker-js/faker@10.5.0)(typescript@7.0.2) @@ -5764,6 +6087,81 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.21.3': optional: true + '@oxfmt/binding-android-arm-eabi@0.70.0': + optional: true + + '@oxfmt/binding-android-arm64@0.70.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.70.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.70.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.70.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.70.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.70.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.70.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2003': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2003': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2003': + optional: true + '@oxlint/binding-android-arm-eabi@1.85.0': optional: true @@ -5905,6 +6303,10 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@shadcn/helpers@0.2.0(ai@7.0.118(zod@4.4.3))': + optionalDependencies: + ai: 7.0.118(zod@4.4.3) + '@shadcn/lint@0.2.0': dependencies: '@eslint/core': 0.17.0 @@ -6009,10 +6411,24 @@ snapshots: tailwindcss: 4.3.3 vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + '@tanstack/devtools-event-client@0.5.0': {} + '@tanstack/history@1.162.0': {} + '@tanstack/pacer@0.22.0': + dependencies: + '@tanstack/devtools-event-client': 0.5.0 + '@tanstack/store': 0.11.1 + '@tanstack/query-core@5.101.2': {} + '@tanstack/react-pacer@0.23.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/pacer': 0.22.0 + '@tanstack/react-store': 0.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-query@5.101.2(react@19.2.7)': dependencies: '@tanstack/query-core': 5.101.2 @@ -6112,6 +6528,13 @@ snapshots: - vite-plugin-solid - webpack + '@tanstack/react-store@0.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.11.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/store': 0.9.3 @@ -6244,6 +6667,8 @@ snapshots: dependencies: '@tanstack/router-core': 1.171.14 + '@tanstack/store@0.11.1': {} + '@tanstack/store@0.9.3': {} '@tanstack/virtual-file-routes@1.162.0': {} @@ -6408,6 +6833,8 @@ snapshots: '@ungap/structured-clone@1.4.0': {} + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -6454,6 +6881,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@workflow/serde@4.1.0': {} + '@yuta/bird@0.10.1': dependencies: commander: 14.0.3 @@ -6467,6 +6896,22 @@ snapshots: acorn@8.18.0: {} + ai-sdk-provider-codex-cli@2.3.0(zod@4.4.3): + dependencies: + '@ai-sdk/provider': 4.0.18 + '@ai-sdk/provider-utils': 5.0.49(zod@4.4.3) + jsonc-parser: 3.3.1 + zod: 4.4.3 + optionalDependencies: + '@openai/codex': 0.156.1 + + ai@7.0.118(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 4.0.96(zod@4.4.3) + '@ai-sdk/provider': 4.0.18 + '@ai-sdk/provider-utils': 5.0.49(zod@4.4.3) + zod: 4.4.3 + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -6840,6 +7285,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} entities@8.0.0: {} @@ -7191,6 +7638,43 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.4.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.4.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.9 @@ -7211,10 +7695,28 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.5 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + hono@4.13.9: {} hookable@6.1.1: {} @@ -7227,6 +7729,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -7395,8 +7899,12 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -7531,6 +8039,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.6: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -8158,7 +8668,40 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.21.3 '@oxc-resolver/binding-win32-x64-msvc': 11.21.3 - oxlint@1.85.0: + oxfmt@0.70.0: + dependencies: + tinypool: 2.1.2 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.70.0 + '@oxfmt/binding-android-arm64': 0.70.0 + '@oxfmt/binding-darwin-arm64': 0.70.0 + '@oxfmt/binding-darwin-x64': 0.70.0 + '@oxfmt/binding-freebsd-x64': 0.70.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.70.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.70.0 + '@oxfmt/binding-linux-arm64-gnu': 0.70.0 + '@oxfmt/binding-linux-arm64-musl': 0.70.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-musl': 0.70.0 + '@oxfmt/binding-linux-s390x-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-musl': 0.70.0 + '@oxfmt/binding-openharmony-arm64': 0.70.0 + '@oxfmt/binding-win32-arm64-msvc': 0.70.0 + '@oxfmt/binding-win32-ia32-msvc': 0.70.0 + '@oxfmt/binding-win32-x64-msvc': 0.70.0 + + oxlint-tsgolint@7.0.2003: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2003 + '@oxlint-tsgolint/darwin-x64': 7.0.2003 + '@oxlint-tsgolint/linux-arm64': 7.0.2003 + '@oxlint-tsgolint/linux-x64': 7.0.2003 + '@oxlint-tsgolint/win32-arm64': 7.0.2003 + '@oxlint-tsgolint/win32-x64': 7.0.2003 + + oxlint@1.85.0(oxlint-tsgolint@7.0.2003): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.85.0 '@oxlint/binding-android-arm64': 1.85.0 @@ -8179,6 +8722,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.85.0 '@oxlint/binding-win32-ia32-msvc': 1.85.0 '@oxlint/binding-win32-x64-msvc': 1.85.0 + oxlint-tsgolint: 7.0.2003 p-limit@2.3.0: dependencies: @@ -8223,6 +8767,10 @@ snapshots: parse-srcset@1.0.2: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -8327,24 +8875,6 @@ snapshots: react-is@17.0.2: {} - react-markdown@10.1.0(@types/react@19.2.17)(react@19.2.7): - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@types/react': 19.2.17 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.1 - react: 19.2.7 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - unified: 11.0.5 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - react@19.2.7: {} readdirp@5.0.0: {} @@ -8362,6 +8892,21 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + rehype-harden@1.1.8: + dependencies: + unist-util-visit: 5.1.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-sanitize: 5.0.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -8398,6 +8943,8 @@ snapshots: remeda@2.39.0: {} + remend@1.3.1: {} + require-from-string@2.0.2: {} reselect@5.3.0: {} @@ -8622,6 +9169,28 @@ snapshots: stdin-discarder@0.2.2: {} + streamdown@2.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + rehype-harden: 1.1.8 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.3.1 + tailwind-merge: 3.7.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + string-argv@0.3.2: {} string-width@7.2.0: @@ -8673,6 +9242,8 @@ snapshots: systeminformation@5.33.13: {} + tailwind-merge@3.7.0: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -8688,6 +9259,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinypool@2.1.2: {} + tinyrainbow@3.1.0: {} tldts-core@7.4.8: {} @@ -8791,6 +9364,8 @@ snapshots: undici@7.28.0: {} + undici@7.30.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -8873,6 +9448,11 @@ snapshots: vary@1.1.2: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -8936,6 +9516,8 @@ snapshots: walk-up-path@4.0.0: {} + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} webpack-virtual-modules@0.6.2: {} diff --git a/scripts/serve-codex.mjs b/scripts/serve-codex.mjs deleted file mode 100644 index 57e2f1a..0000000 --- a/scripts/serve-codex.mjs +++ /dev/null @@ -1,94 +0,0 @@ -import { spawn, spawnSync } from 'node:child_process' -import { mkdir } from 'node:fs/promises' -import { homedir } from 'node:os' -import { resolve } from 'node:path' - -// This process is independent of Vite / the web backend. Keep it running while -// restarting the app. Codex owns its own login and refreshes that login normally. -const reportRoot = resolve( - process.env.TWITTER_LITE_REPORT_ROOT || '.data/research', -) -const endpoint = new URL( - process.env.TWITTER_LITE_CODEX_URL || 'ws://127.0.0.1:4500', -) -if ( - endpoint.protocol !== 'ws:' || - endpoint.hostname !== '127.0.0.1' || - endpoint.username || - endpoint.password -) { - throw new Error('The prototype Codex endpoint must be ws://127.0.0.1:.') -} -await mkdir(reportRoot, { recursive: true, mode: 0o700 }) -const env = { - HOME: homedir(), - PATH: process.env.PATH, - CODEX_HOME: process.env.CODEX_HOME || resolve(homedir(), '.codex'), -} -for (const name of [ - 'LANG', - 'LC_ALL', - 'SSL_CERT_FILE', - 'SSL_CERT_DIR', - 'NIX_SSL_CERT_FILE', - 'TMPDIR', -]) { - if (process.env[name]) env[name] = process.env[name] -} -// Enumerate the effective config, including CLI-wrapper and project settings. -// Keep this output private: MCP transport settings can contain credentials. -const configured = spawnSync('codex', ['mcp', 'list', '--json'], { - cwd: reportRoot, - env, - encoding: 'utf8', - timeout: 15_000, - maxBuffer: 4 * 1024 * 1024, -}) -let mcpOverrides -try { - if (configured.error || configured.status !== 0) throw new Error() - const servers = JSON.parse(configured.stdout) - if (!Array.isArray(servers)) throw new Error() - mcpOverrides = servers.flatMap((server) => { - if ( - typeof server?.name !== 'string' || - !/^[A-Za-z0-9_-]+$/.test(server.name) - ) - throw new Error() - return ['-c', `mcp_servers.${server.name}.enabled=false`] - }) -} catch { - throw new Error( - 'Could not inspect Codex MCP configuration safely. Check codex mcp list locally.', - ) -} -const child = spawn( - 'codex', - [ - // Keep overrides before the subcommand so they merge with wrapper settings. - '-c', - 'shell_environment_policy.inherit="none"', - '-c', - 'features.multi_agent=false', - '-c', - 'features.hooks=false', - '-c', - 'web_search="disabled"', - ...mcpOverrides, - 'app-server', - '--listen', - endpoint.origin, - ], - { cwd: reportRoot, env, stdio: 'inherit' }, -) -child.on('error', () => { - console.error( - 'Could not start Codex. Install codex and run codex login first.', - ) - process.exitCode = 1 -}) -child.on('exit', (code) => { - process.exitCode = code ?? 1 -}) -for (const signal of ['SIGINT', 'SIGTERM']) - process.on(signal, () => child.kill(signal)) diff --git a/src/base.css b/src/base.css new file mode 100644 index 0000000..755cfed --- /dev/null +++ b/src/base.css @@ -0,0 +1,75 @@ +:root { + --canvas: var(--color-canvas); + --surface: var(--color-paper-2); + --paper: var(--color-paper); + --ink: var(--color-ink); + --accent-hover: var(--color-accent-hover); + --control-border: var(--color-control-rule); + --error: var(--color-error); + color: var(--ink); + background: var(--canvas); + color-scheme: dark; + font-family: var(--font-body); + font-size: 14px; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +html, +body { + min-width: 320px; + overflow-x: clip; + background: var(--canvas); +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +a { + color: var(--primary); + text-underline-offset: 0.18em; +} +a:active { + color: var(--accent-hover); +} +:focus-visible { + outline: var(--rule-active) solid var(--color-focus); + outline-offset: 2px; +} +h1 { + margin: 0; + min-width: 0; + font-family: var(--font-display); + font-size: var(--text-display-s); + font-weight: 700; + line-height: 1.3; + overflow-wrap: anywhere; +} +.handle { + display: block; + color: var(--muted-foreground); + font-size: var(--text-xs); +} + +.workspace .reader { + overflow: hidden; +} +.sidebar-note { + color: var(--muted-foreground); + font-size: var(--text-xs); +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + /* Respect reduced motion over registry component animation utilities. */ + animation-duration: 0.01ms !important; + /* Respect reduced motion over registry component transition utilities. */ + transition-duration: 0.01ms !important; + } +} diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index e700b77..824684f 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,5 +1,5 @@ -import { Columns3, MessageSquare, PanelLeftClose } from 'lucide-react' -import { type CSSProperties, type ReactNode, useState } from 'react' +import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react"; +import { type CSSProperties, type ReactNode, useState } from "react"; import { Sidebar, SidebarInset, @@ -8,16 +8,16 @@ import { SidebarProvider, SidebarTrigger, useSidebar, -} from '#/components/ui/sidebar' -import { TooltipProvider } from '#/components/ui/tooltip' -import { WorkspaceNavigation } from '#/components/workspace-navigation' +} from "#/components/ui/sidebar"; +import { TooltipProvider } from "#/components/ui/tooltip"; +import { WorkspaceNavigation } from "#/components/workspace-navigation"; type ShellProps = { - hasTemporaryDecks: boolean - sidebar: ReactNode - researchChat: (closeMobileSidebar: () => void) => ReactNode - children: ReactNode -} + hasTemporaryDecks: boolean; + sidebar: ReactNode; + researchChat: (closeMobileSidebar: () => void) => ReactNode; + children: ReactNode; +}; export function AppShell(props: ShellProps) { return ( @@ -26,29 +26,24 @@ export function AppShell(props: ShellProps) { className="workspace h-dvh min-h-0 overflow-hidden" style={ { - '--sidebar-width': '25rem', - '--sidebar-width-icon': '4rem', + "--sidebar-width": "25rem", + "--sidebar-width-icon": "4rem", } as CSSProperties } > - ) + ); } -function Workspace({ - sidebar, - researchChat, - children, - hasTemporaryDecks, -}: ShellProps) { - const [panel, setPanel] = useState<'chat' | 'decks'>('chat') - const { setOpen, setOpenMobile, isMobile } = useSidebar() +function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: ShellProps) { + const [panel, setPanel] = useState<"chat" | "decks">("chat"); + const { setOpen, setOpenMobile, isMobile } = useSidebar(); const navigation = [ - { id: 'chat', title: 'Research chat', icon: MessageSquare }, - { id: 'decks', title: 'Deck manager', icon: Columns3 }, - ] as const + { id: "chat", title: "Research chat", icon: MessageSquare }, + { id: "decks", title: "Deck manager", icon: Columns3 }, + ] as const; return ( <> @@ -62,10 +57,8 @@ function Workspace({ - isMobile ? setOpenMobile(false) : setOpen(false) - } + tooltip={{ children: "Close sidebar", hidden: false }} + onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))} className="size-10 justify-center p-0" > @@ -80,9 +73,9 @@ function Workspace({ tooltip={{ children: item.title, hidden: false }} isActive={panel === item.id} onClick={() => { - setPanel(item.id) - if (isMobile) setOpenMobile(true) - else setOpen(true) + setPanel(item.id); + if (isMobile) setOpenMobile(true); + else setOpen(true); }} className="size-10 justify-center p-0" > @@ -92,23 +85,14 @@ function Workspace({ ))} - -
+ +
{researchChat(() => setOpenMobile(false))}

Decks

@@ -120,17 +104,12 @@ function Workspace({
Research - - Research workspace - + Research workspace
-
+
{children}
- ) + ); } diff --git a/src/components/dialog.tsx b/src/components/dialog.tsx index 791892b..d1f620e 100644 --- a/src/components/dialog.tsx +++ b/src/components/dialog.tsx @@ -1,13 +1,13 @@ -import { cn } from 'cn' -import { Icon } from './icon' -import { Button } from './ui/button' +import { cn } from "cn"; +import { Icon } from "./icon"; +import { Button } from "./ui/button"; import { DialogClose, DialogContent, DialogHeader, Dialog as DialogRoot, DialogTitle, -} from './ui/dialog' +} from "./ui/dialog"; export function Dialog({ title, @@ -15,21 +15,21 @@ export function Dialog({ children, className, }: { - title: string - className?: string - onClose: () => void - children: React.ReactNode + title: string; + className?: string; + onClose: () => void; + children: React.ReactNode; }) { return ( { - if (!open) onClose() + if (!open) onClose(); }} > @@ -50,5 +50,5 @@ export function Dialog({ {children} - ) + ); } diff --git a/src/components/icon.tsx b/src/components/icon.tsx index b2d7cb9..920d4c8 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -1,17 +1,17 @@ const paths = { - columns: 'M3 4h7v16H3z M14 4h7v16h-7z', - hashtag: 'M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16', - search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0', - user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0', - list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01', - plus: 'M12 5v14 M5 12h14', - close: 'M6 6l12 12 M18 6 6 18', - refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6', - more: 'M5 12h.01 M12 12h.01 M19 12h.01', - arrow: 'M7 17 17 7 M7 7h10v10', - edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5', - trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7', -} as const + columns: "M3 4h7v16H3z M14 4h7v16h-7z", + hashtag: "M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16", + search: "M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0", + user: "M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0", + list: "M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01", + plus: "M12 5v14 M5 12h14", + close: "M6 6l12 12 M18 6 6 18", + refresh: "M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6", + more: "M5 12h.01 M12 12h.01 M19 12h.01", + arrow: "M7 17 17 7 M7 7h10v10", + edit: "m16 3 5 5-12 12H4v-5z M14 5l5 5", + trash: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7", +} as const; export function Icon({ name }: { name: keyof typeof paths }) { return ( @@ -28,5 +28,5 @@ export function Icon({ name }: { name: keyof typeof paths }) { > - ) + ); } diff --git a/src/components/post-text.tsx b/src/components/post-text.tsx index 2063995..f07c519 100644 --- a/src/components/post-text.tsx +++ b/src/components/post-text.tsx @@ -1,49 +1,44 @@ -import type { ReactNode } from 'react' +import type { ReactNode } from "react"; -const URL = /(https?:\/\/[^\s]+)/g -const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi +const URL = /(https?:\/\/[^\s]+)/g; +const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi; const NAMED_ENTITIES: Record = { - '&': '&', - ''': "'", - '>': '>', - '<': '<', - '"': '"', -} + "&": "&", + "'": "'", + ">": ">", + "<": "<", + """: '"', +}; function decodeHtmlEntities(text: string): string { return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => { if (!decimal && !hexadecimal) { - return NAMED_ENTITIES[entity.toLowerCase()] ?? entity + return NAMED_ENTITIES[entity.toLowerCase()] ?? entity; } - const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16) - return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity - }) + const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16); + return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity; + }); } function linkedText(text: string): ReactNode[] { - const decoded = decodeHtmlEntities(text) - const parts: ReactNode[] = [] - let cursor = 0 + const decoded = decodeHtmlEntities(text); + const parts: ReactNode[] = []; + let cursor = 0; for (const match of decoded.matchAll(URL)) { - const href = match[0] - const start = match.index - if (start > cursor) parts.push(decoded.slice(cursor, start)) + const href = match[0]; + const start = match.index; + if (start > cursor) parts.push(decoded.slice(cursor, start)); parts.push( - + {href} , - ) - cursor = start + href.length + ); + cursor = start + href.length; } - if (cursor < decoded.length) parts.push(decoded.slice(cursor)) - return parts + if (cursor < decoded.length) parts.push(decoded.slice(cursor)); + return parts; } export function PostText({ text }: { text: string }) { - return <>{linkedText(text)} + return <>{linkedText(text)}; } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 1d0fc82..9f25cef 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -1,106 +1,90 @@ -import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar' -import { cn } from 'cn' -import type * as React from 'react' +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; +import { cn } from "cn"; +import type * as React from "react"; function Avatar({ className, - size = 'default', + size = "default", ...props }: AvatarPrimitive.Root.Props & { - size?: 'default' | 'sm' | 'lg' + size?: "default" | "sm" | "lg"; }) { return ( - ) + ); } function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { return ( - ) + ); } -function AvatarFallback({ - className, - ...props -}: AvatarPrimitive.Fallback.Props) { +function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) { return ( - ) + ); } -function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { return ( svg]:hidden', - 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', - 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + "absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none", + "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", className, )} {...props} /> - ) + ); } -function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { return (
- ) + ); } -function AvatarGroupCount({ - className, - ...props -}: React.ComponentProps<'div'>) { +function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", className, )} {...props} /> - ) + ); } -export { - Avatar, - AvatarBadge, - AvatarFallback, - AvatarGroup, - AvatarGroupCount, - AvatarImage, -} +export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 8894ef9..c444f02 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,40 +1,37 @@ -import { mergeProps } from '@base-ui/react/merge-props' -import { useRender } from '@base-ui/react/use-render' -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from 'cn' +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; const badgeVariants = cva( - 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!', + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", { variants: { variant: { - default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', - secondary: - 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", destructive: - 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', - outline: - 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', - ghost: - 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', - link: 'text-primary underline-offset-4 hover:underline', + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", }, }, defaultVariants: { - variant: 'default', + variant: "default", }, }, -) +); function Badge({ className, - variant = 'default', + variant = "default", render, ...props -}: useRender.ComponentProps<'span'> & VariantProps) { +}: useRender.ComponentProps<"span"> & VariantProps) { return useRender({ - defaultTagName: 'span', - props: mergeProps<'span'>( + defaultTagName: "span", + props: mergeProps<"span">( { className: cn(badgeVariants({ variant }), className), }, @@ -42,10 +39,10 @@ function Badge({ ), render, state: { - slot: 'badge', + slot: "badge", variant, }, - }) + }); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/src/components/ui/bubble.tsx b/src/components/ui/bubble.tsx index 8ecebc7..f5f0183 100644 --- a/src/components/ui/bubble.tsx +++ b/src/components/ui/bubble.tsx @@ -1,54 +1,54 @@ -import { mergeProps } from '@base-ui/react/merge-props' -import { useRender } from '@base-ui/react/use-render' -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from 'cn' -import type * as React from 'react' +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import type * as React from "react"; -function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) { +function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) { return (
- ) + ); } const bubbleVariants = cva( - 'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full', + "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full", { variants: { variant: { default: - '*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80', + "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80", secondary: - '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]', + "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", muted: - '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]', + "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]", tinted: - '*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]', + "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]", outline: - '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30', + "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30", ghost: - 'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50', + "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50", destructive: - '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30', + "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30", }, }, defaultVariants: { - variant: 'default', + variant: "default", }, }, -) +); function Bubble({ - variant = 'default', - align = 'start', + variant = "default", + align = "start", className, ...props -}: React.ComponentProps<'div'> & +}: React.ComponentProps<"div"> & VariantProps & { - align?: 'start' | 'end' + align?: "start" | "end"; }) { return (
- ) + ); } -function BubbleContent({ - className, - render, - ...props -}: useRender.ComponentProps<'div'>) { +function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ - defaultTagName: 'div', - props: mergeProps<'div'>( + defaultTagName: "div", + props: mergeProps<"div">( { className: cn( - 'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50', + "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50", className, ), }, @@ -79,39 +75,39 @@ function BubbleContent({ ), render, state: { - slot: 'bubble-content', + slot: "bubble-content", }, - }) + }); } const bubbleReactionsVariants = cva( - 'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0', + "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0", { variants: { side: { - top: 'top-0 -translate-y-3/4', - bottom: 'bottom-0 translate-y-3/4', + top: "top-0 -translate-y-3/4", + bottom: "bottom-0 translate-y-3/4", }, align: { - start: 'left-3', - end: 'right-3', + start: "left-3", + end: "right-3", }, }, defaultVariants: { - side: 'bottom', - align: 'end', + side: "bottom", + align: "end", }, }, -) +); function BubbleReactions({ - side = 'bottom', - align = 'end', + side = "bottom", + align = "end", className, ...props -}: React.ComponentProps<'div'> & { - align?: 'start' | 'end' - side?: 'top' | 'bottom' +}: React.ComponentProps<"div"> & { + align?: "start" | "end"; + side?: "top" | "bottom"; }) { return (
- ) + ); } -export { Bubble, BubbleContent, BubbleGroup, BubbleReactions } +export { Bubble, BubbleContent, BubbleGroup, BubbleReactions }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 34b315f..4212cbb 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,48 +1,48 @@ -import { Button as ButtonPrimitive } from '@base-ui/react/button' -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from 'cn' +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/80', + default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: - 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: - 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: - 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: - 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', - link: 'text-primary underline-offset-4 hover:underline', + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", }, size: { default: - 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', - icon: 'size-8', - 'icon-xs': + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - 'icon-sm': - 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', - 'icon-lg': 'size-9', + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, }, -) +); function Button({ className, - variant = 'default', - size = 'default', + variant = "default", + size = "default", ...props }: ButtonPrimitive.Props & VariantProps) { return ( @@ -51,7 +51,7 @@ function Button({ className={cn(buttonVariants({ variant, size, className }))} {...props} /> - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 51671b6..776a167 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -1,13 +1,13 @@ -import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox' -import { cn } from 'cn' -import { CheckIcon } from 'lucide-react' +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; +import { cn } from "cn"; +import { CheckIcon } from "lucide-react"; function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { return ( - ) + ); } -export { Checkbox } +export { Checkbox }; diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx index ab50e3e..1705aa3 100644 --- a/src/components/ui/collapsible.tsx +++ b/src/components/ui/collapsible.tsx @@ -1,19 +1,15 @@ -import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible' +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"; function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { - return + return ; } function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { - return ( - - ) + return ; } function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { - return ( - - ) + return ; } -export { Collapsible, CollapsibleContent, CollapsibleTrigger } +export { Collapsible, CollapsibleContent, CollapsibleTrigger }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 858a8b1..b1bda4f 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,39 +1,36 @@ -import { Dialog as DialogPrimitive } from '@base-ui/react/dialog' -import { cn } from 'cn' -import { XIcon } from 'lucide-react' -import type * as React from 'react' -import { Button } from '#/components/ui/button.tsx' +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { cn } from "cn"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; +import { Button } from "#/components/ui/button.tsx"; function Dialog({ ...props }: DialogPrimitive.Root.Props) { - return + return ; } function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { - return + return ; } function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { - return + return ; } function DialogClose({ ...props }: DialogPrimitive.Close.Props) { - return + return ; } -function DialogOverlay({ - className, - ...props -}: DialogPrimitive.Backdrop.Props) { +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { return ( - ) + ); } function DialogContent({ @@ -42,7 +39,7 @@ function DialogContent({ showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { - showCloseButton?: boolean + showCloseButton?: boolean; }) { return ( @@ -50,7 +47,7 @@ function DialogContent({ - } + render={