@symbiosis-lab/moss-plugin-matters 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Direct HTTP client for E2E tests against Matters API
3
+ * Uses fetch directly without Tauri IPC
4
+ */
5
+
6
+ export interface GraphQLResponse<T> {
7
+ data?: T;
8
+ errors?: Array<{ message: string }>;
9
+ }
10
+
11
+ const DEFAULT_ENDPOINT = "https://server.matters.icu/graphql";
12
+
13
+ export async function graphqlQuery<T>(
14
+ query: string,
15
+ variables?: Record<string, unknown>,
16
+ endpoint = DEFAULT_ENDPOINT
17
+ ): Promise<T> {
18
+ const response = await fetch(endpoint, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ },
23
+ body: JSON.stringify({ query, variables }),
24
+ });
25
+
26
+ if (!response.ok) {
27
+ throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
28
+ }
29
+
30
+ const result: GraphQLResponse<T> = await response.json();
31
+
32
+ if (result.errors && result.errors.length > 0) {
33
+ throw new Error(`GraphQL error: ${result.errors[0].message}`);
34
+ }
35
+
36
+ if (!result.data) {
37
+ throw new Error("No data returned from GraphQL query");
38
+ }
39
+
40
+ return result.data;
41
+ }
42
+
43
+ // Pre-defined queries for testing
44
+
45
+ export const USER_ARTICLES_QUERY = `
46
+ query UserArticles($userName: String!, $after: String) {
47
+ user(input: { userName: $userName }) {
48
+ id
49
+ userName
50
+ articles(input: { first: 50, after: $after, filter: { state: active } }) {
51
+ totalCount
52
+ pageInfo {
53
+ endCursor
54
+ hasNextPage
55
+ }
56
+ edges {
57
+ node {
58
+ id
59
+ title
60
+ slug
61
+ shortHash
62
+ content
63
+ summary
64
+ createdAt
65
+ revisedAt
66
+ tags {
67
+ id
68
+ content
69
+ }
70
+ cover
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ `;
77
+
78
+ export const USER_PROFILE_QUERY = `
79
+ query UserProfile($userName: String!) {
80
+ user(input: { userName: $userName }) {
81
+ id
82
+ userName
83
+ displayName
84
+ info {
85
+ description
86
+ profileCover
87
+ }
88
+ avatar
89
+ settings {
90
+ language
91
+ }
92
+ }
93
+ }
94
+ `;
95
+
96
+ export const USER_COLLECTIONS_QUERY = `
97
+ query UserCollections($userName: String!, $after: String) {
98
+ user(input: { userName: $userName }) {
99
+ id
100
+ collections(input: { first: 50, after: $after }) {
101
+ totalCount
102
+ pageInfo {
103
+ endCursor
104
+ hasNextPage
105
+ }
106
+ edges {
107
+ node {
108
+ id
109
+ title
110
+ description
111
+ cover
112
+ articles(input: { first: 100 }) {
113
+ edges {
114
+ node {
115
+ id
116
+ shortHash
117
+ title
118
+ slug
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ `;
128
+
129
+ // Convenience functions for common operations
130
+
131
+ export interface UserArticle {
132
+ id: string;
133
+ title: string;
134
+ slug: string;
135
+ shortHash: string;
136
+ content: string;
137
+ summary: string;
138
+ createdAt: string;
139
+ revisedAt?: string;
140
+ cover?: string;
141
+ tags?: Array<{ id: string; content: string }>;
142
+ }
143
+
144
+ export interface UserProfile {
145
+ id: string;
146
+ userName: string;
147
+ displayName: string;
148
+ avatar?: string;
149
+ info?: {
150
+ description?: string;
151
+ profileCover?: string;
152
+ };
153
+ settings?: {
154
+ language?: string;
155
+ };
156
+ }
157
+
158
+ export interface UserCollection {
159
+ id: string;
160
+ title: string;
161
+ description?: string;
162
+ cover?: string;
163
+ articles: {
164
+ edges?: Array<{
165
+ node: {
166
+ id: string;
167
+ shortHash: string;
168
+ title: string;
169
+ slug: string;
170
+ };
171
+ }>;
172
+ };
173
+ }
174
+
175
+ export async function fetchUserArticles(
176
+ userName: string,
177
+ endpoint = DEFAULT_ENDPOINT
178
+ ): Promise<UserArticle[]> {
179
+ const allArticles: UserArticle[] = [];
180
+ let cursor: string | undefined;
181
+
182
+ do {
183
+ const data = await graphqlQuery<{
184
+ user?: {
185
+ articles: {
186
+ edges?: Array<{ node: UserArticle }>;
187
+ pageInfo: { endCursor?: string; hasNextPage: boolean };
188
+ };
189
+ };
190
+ }>(USER_ARTICLES_QUERY, { userName, after: cursor }, endpoint);
191
+
192
+ if (!data.user) {
193
+ throw new Error(`User not found: ${userName}`);
194
+ }
195
+
196
+ const edges = data.user.articles.edges ?? [];
197
+ for (const edge of edges) {
198
+ allArticles.push(edge.node);
199
+ }
200
+
201
+ cursor = data.user.articles.pageInfo.hasNextPage
202
+ ? data.user.articles.pageInfo.endCursor
203
+ : undefined;
204
+ } while (cursor);
205
+
206
+ return allArticles;
207
+ }
208
+
209
+ export async function fetchUserProfile(
210
+ userName: string,
211
+ endpoint = DEFAULT_ENDPOINT
212
+ ): Promise<UserProfile | null> {
213
+ const data = await graphqlQuery<{
214
+ user?: UserProfile;
215
+ }>(USER_PROFILE_QUERY, { userName }, endpoint);
216
+
217
+ return data.user ?? null;
218
+ }
219
+
220
+ export async function fetchUserCollections(
221
+ userName: string,
222
+ endpoint = DEFAULT_ENDPOINT
223
+ ): Promise<UserCollection[]> {
224
+ const allCollections: UserCollection[] = [];
225
+ let cursor: string | undefined;
226
+
227
+ do {
228
+ const data = await graphqlQuery<{
229
+ user?: {
230
+ collections: {
231
+ edges?: Array<{ node: UserCollection }>;
232
+ pageInfo: { endCursor?: string; hasNextPage: boolean };
233
+ };
234
+ };
235
+ }>(USER_COLLECTIONS_QUERY, { userName, after: cursor }, endpoint);
236
+
237
+ if (!data.user) {
238
+ throw new Error(`User not found: ${userName}`);
239
+ }
240
+
241
+ const edges = data.user.collections.edges ?? [];
242
+ for (const edge of edges) {
243
+ allCollections.push(edge.node);
244
+ }
245
+
246
+ cursor = data.user.collections.pageInfo.hasNextPage
247
+ ? data.user.collections.pageInfo.endCursor
248
+ : undefined;
249
+ } while (cursor);
250
+
251
+ return allCollections;
252
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Sample article fixtures for testing
3
+ */
4
+
5
+ import type { MattersArticle, MattersDraft, MattersCollection, MattersUserProfile } from "../../src/types";
6
+
7
+ export const sampleArticle: MattersArticle = {
8
+ id: "QXJ0aWNsZToxMjM0NQ==",
9
+ title: "Test Article Title",
10
+ slug: "test-article-title",
11
+ shortHash: "abc123",
12
+ content: "<p>This is the article content with <strong>bold</strong> and <em>italic</em> text.</p>",
13
+ summary: "A brief summary of the article",
14
+ createdAt: "2024-01-15T10:30:00Z",
15
+ revisedAt: "2024-01-20T15:45:00Z",
16
+ cover: "https://assets.matters.town/processed/abc123-cover.jpg",
17
+ tags: [
18
+ { id: "VGFnOjE=", content: "Technology" },
19
+ { id: "VGFnOjI=", content: "Testing" },
20
+ ],
21
+ };
22
+
23
+ export const sampleArticleWithImages: MattersArticle = {
24
+ id: "QXJ0aWNsZTo2Nzg5MA==",
25
+ title: "Article with Images",
26
+ slug: "article-with-images",
27
+ shortHash: "def456",
28
+ content: `
29
+ <p>Introduction paragraph.</p>
30
+ <figure class="image">
31
+ <img src="https://assets.matters.town/processed/image1.jpg" alt="First image">
32
+ <figcaption>Caption for first image</figcaption>
33
+ </figure>
34
+ <p>Middle paragraph.</p>
35
+ <figure class="image">
36
+ <img src="https://assets.matters.town/processed/image2.png" alt="Second image">
37
+ </figure>
38
+ <p>Conclusion paragraph.</p>
39
+ `,
40
+ summary: "An article with embedded images",
41
+ createdAt: "2024-02-01T08:00:00Z",
42
+ tags: [],
43
+ };
44
+
45
+ export const sampleDraft: MattersDraft = {
46
+ id: "RHJhZnQ6OTg3NjU=",
47
+ title: "Draft in Progress",
48
+ content: "<p>This is a draft that is still being written.</p>",
49
+ summary: "Draft summary",
50
+ createdAt: "2024-02-10T12:00:00Z",
51
+ updatedAt: "2024-02-11T14:30:00Z",
52
+ tags: ["WIP", "Ideas"],
53
+ cover: undefined,
54
+ };
55
+
56
+ export const sampleCollection: MattersCollection = {
57
+ id: "Q29sbGVjdGlvbjoxMjM=",
58
+ title: "My Best Articles",
59
+ description: "A collection of my favorite articles",
60
+ cover: "https://assets.matters.town/processed/collection-cover.jpg",
61
+ articles: [
62
+ { id: "1", shortHash: "abc123", title: "Test Article Title", slug: "test-article-title" },
63
+ { id: "2", shortHash: "def456", title: "Article with Images", slug: "article-with-images" },
64
+ ],
65
+ };
66
+
67
+ export const sampleUserProfile: MattersUserProfile = {
68
+ userName: "testuser",
69
+ displayName: "Test User",
70
+ description: "A test user for unit tests",
71
+ avatar: "https://assets.matters.town/avatar/test.jpg",
72
+ profileCover: "https://assets.matters.town/cover/test.jpg",
73
+ language: "en",
74
+ };
75
+
76
+ export const sampleChineseUserProfile: MattersUserProfile = {
77
+ userName: "zhongwen",
78
+ displayName: "Traditional Chinese User",
79
+ description: "Traditional Chinese User Description",
80
+ avatar: "https://assets.matters.town/avatar/zh.jpg",
81
+ language: "zh_hant",
82
+ };
83
+
84
+ // Create multiple articles for pagination testing
85
+ export function createMultipleArticles(count: number): MattersArticle[] {
86
+ return Array.from({ length: count }, (_, i) => ({
87
+ id: `QXJ0aWNsZToke2krMX0=`,
88
+ title: `Article ${i + 1}`,
89
+ slug: `article-${i + 1}`,
90
+ shortHash: `hash${i + 1}`,
91
+ content: `<p>Content for article ${i + 1}</p>`,
92
+ summary: `Summary for article ${i + 1}`,
93
+ createdAt: new Date(Date.now() - i * 86400000).toISOString(),
94
+ tags: [{ id: `VGFnOiR7aX0=`, content: `Tag${i % 5}` }],
95
+ }));
96
+ }
97
+
98
+ // Create articles that belong to multiple collections (for file mode testing)
99
+ export function createMultiCollectionArticles(): {
100
+ articles: MattersArticle[];
101
+ collections: MattersCollection[];
102
+ } {
103
+ const articles: MattersArticle[] = [
104
+ {
105
+ id: "1",
106
+ title: "Shared Article",
107
+ slug: "shared-article",
108
+ shortHash: "shared1",
109
+ content: "<p>This article belongs to multiple collections</p>",
110
+ summary: "Shared article summary",
111
+ createdAt: "2024-01-01T00:00:00Z",
112
+ tags: [],
113
+ },
114
+ {
115
+ id: "2",
116
+ title: "Exclusive Article",
117
+ slug: "exclusive-article",
118
+ shortHash: "excl1",
119
+ content: "<p>This article belongs to only one collection</p>",
120
+ summary: "Exclusive article summary",
121
+ createdAt: "2024-01-02T00:00:00Z",
122
+ tags: [],
123
+ },
124
+ ];
125
+
126
+ const collections: MattersCollection[] = [
127
+ {
128
+ id: "col1",
129
+ title: "Collection A",
130
+ description: "First collection",
131
+ articles: [
132
+ { id: "1", shortHash: "shared1", title: "Shared Article", slug: "shared-article" },
133
+ { id: "2", shortHash: "excl1", title: "Exclusive Article", slug: "exclusive-article" },
134
+ ],
135
+ },
136
+ {
137
+ id: "col2",
138
+ title: "Collection B",
139
+ description: "Second collection",
140
+ articles: [
141
+ { id: "1", shortHash: "shared1", title: "Shared Article", slug: "shared-article" },
142
+ ],
143
+ },
144
+ ];
145
+
146
+ return { articles, collections };
147
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Ethereum Wallet Authentication for E2E Testing
3
+ *
4
+ * Uses Ethereum wallet signature to authenticate with Matters.icu (test environment).
5
+ * This allows programmatic login without email verification.
6
+ *
7
+ * Authentication Flow (EIP-4361 Sign-In with Ethereum):
8
+ * 1. Generate signing message with wallet address
9
+ * 2. Sign the message with private key
10
+ * 3. Submit signature to login/signup
11
+ * 4. Receive auth token
12
+ *
13
+ * Environment Variables:
14
+ * - MATTERS_TEST_WALLET_PRIVATE_KEY: Ethereum private key for test account
15
+ * - MATTERS_TEST_ENDPOINT: GraphQL endpoint (default: https://server.matters.icu/graphql)
16
+ */
17
+
18
+ import { graphqlQuery } from "./api-client";
19
+
20
+ // ============================================================================
21
+ // Configuration
22
+ // ============================================================================
23
+
24
+ const DEFAULT_ENDPOINT = "https://server.matters.icu/graphql";
25
+
26
+ function getEnv(key: string): string | undefined {
27
+ return process.env[key];
28
+ }
29
+
30
+ // ============================================================================
31
+ // GraphQL Mutations
32
+ // ============================================================================
33
+
34
+ const GENERATE_SIGNING_MESSAGE_MUTATION = `
35
+ mutation GenerateSigningMessage($input: GenerateSigningMessageInput!) {
36
+ generateSigningMessage(input: $input) {
37
+ nonce
38
+ purpose
39
+ signingMessage
40
+ createdAt
41
+ expiredAt
42
+ }
43
+ }
44
+ `;
45
+
46
+ const WALLET_LOGIN_MUTATION = `
47
+ mutation WalletLogin($input: WalletLoginInput!) {
48
+ walletLogin(input: $input) {
49
+ auth
50
+ token
51
+ type
52
+ user {
53
+ id
54
+ userName
55
+ displayName
56
+ }
57
+ }
58
+ }
59
+ `;
60
+
61
+ // ============================================================================
62
+ // Types
63
+ // ============================================================================
64
+
65
+ interface GenerateSigningMessageResponse {
66
+ generateSigningMessage: {
67
+ nonce: string;
68
+ purpose: string;
69
+ signingMessage: string;
70
+ createdAt: string;
71
+ expiredAt: string;
72
+ };
73
+ }
74
+
75
+ interface WalletLoginResponse {
76
+ walletLogin: {
77
+ auth: boolean;
78
+ token: string | null;
79
+ type: "Login" | "Signup" | "LinkAccount";
80
+ user: {
81
+ id: string;
82
+ userName: string;
83
+ displayName: string;
84
+ } | null;
85
+ };
86
+ }
87
+
88
+ export interface WalletAuthResult {
89
+ token: string;
90
+ user: {
91
+ id: string;
92
+ userName: string;
93
+ displayName: string;
94
+ };
95
+ type: "Login" | "Signup" | "LinkAccount";
96
+ }
97
+
98
+ // ============================================================================
99
+ // Wallet Utilities
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Simple Ethereum signing implementation using Web Crypto API
104
+ * Note: For production, use ethers.js or viem
105
+ */
106
+
107
+ // keccak256 hash function (simplified for personal_sign)
108
+ // In real implementation, use ethers.js or viem
109
+ async function signMessage(message: string, privateKey: string): Promise<string> {
110
+ // For now, we'll use dynamic import of ethers since it's commonly available
111
+ // In CI, we'll need to ensure ethers is installed as a dev dependency
112
+ try {
113
+ // Try to use ethers.js if available
114
+ const { Wallet } = await import("ethers");
115
+ const wallet = new Wallet(privateKey);
116
+ return wallet.signMessage(message);
117
+ } catch {
118
+ throw new Error(
119
+ "ethers.js is required for wallet signing. Install with: npm install --save-dev ethers"
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Derive Ethereum address from private key
126
+ */
127
+ async function getAddressFromPrivateKey(privateKey: string): Promise<string> {
128
+ try {
129
+ const { Wallet } = await import("ethers");
130
+ const wallet = new Wallet(privateKey);
131
+ return wallet.address;
132
+ } catch {
133
+ throw new Error(
134
+ "ethers.js is required for address derivation. Install with: npm install --save-dev ethers"
135
+ );
136
+ }
137
+ }
138
+
139
+ // ============================================================================
140
+ // Authentication Functions
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Authenticate with Matters using Ethereum wallet
145
+ *
146
+ * @param privateKey - Ethereum private key (hex string, with or without 0x prefix)
147
+ * @param endpoint - GraphQL endpoint (default: matters.icu test environment)
148
+ * @returns Authentication result with token and user info
149
+ */
150
+ export async function walletLogin(
151
+ privateKey?: string,
152
+ endpoint = DEFAULT_ENDPOINT
153
+ ): Promise<WalletAuthResult> {
154
+ // Get private key from param or environment
155
+ const key = privateKey || getEnv("MATTERS_TEST_WALLET_PRIVATE_KEY");
156
+ if (!key) {
157
+ throw new Error(
158
+ "Private key required. Pass as parameter or set MATTERS_TEST_WALLET_PRIVATE_KEY environment variable."
159
+ );
160
+ }
161
+
162
+ // Normalize private key (ensure 0x prefix)
163
+ const normalizedKey = key.startsWith("0x") ? key : `0x${key}`;
164
+
165
+ // Step 1: Get wallet address
166
+ const address = await getAddressFromPrivateKey(normalizedKey);
167
+ console.log(`🔐 Authenticating with wallet: ${address}`);
168
+
169
+ // Step 2: Generate signing message
170
+ console.log(" Generating signing message...");
171
+ const signingData = await graphqlQuery<GenerateSigningMessageResponse>(
172
+ GENERATE_SIGNING_MESSAGE_MUTATION,
173
+ {
174
+ input: {
175
+ address,
176
+ purpose: "login",
177
+ },
178
+ },
179
+ endpoint
180
+ );
181
+
182
+ const { nonce, signingMessage } = signingData.generateSigningMessage;
183
+ console.log(` Nonce: ${nonce}`);
184
+
185
+ // Step 3: Sign the message
186
+ console.log(" Signing message...");
187
+ const signature = await signMessage(signingMessage, normalizedKey);
188
+ console.log(` Signature: ${signature.substring(0, 20)}...`);
189
+
190
+ // Step 4: Login with signature
191
+ console.log(" Submitting login...");
192
+ const loginData = await graphqlQuery<WalletLoginResponse>(
193
+ WALLET_LOGIN_MUTATION,
194
+ {
195
+ input: {
196
+ ethAddress: address,
197
+ nonce,
198
+ signature,
199
+ signedMessage: signingMessage,
200
+ },
201
+ },
202
+ endpoint
203
+ );
204
+
205
+ const { auth, token, type, user } = loginData.walletLogin;
206
+
207
+ if (!auth || !token || !user) {
208
+ throw new Error("Login failed: auth returned false or no token received");
209
+ }
210
+
211
+ console.log(` ✅ ${type}: @${user.userName}`);
212
+
213
+ return {
214
+ token,
215
+ user,
216
+ type,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Create an authenticated GraphQL query function
222
+ *
223
+ * @param token - Authentication token from walletLogin
224
+ * @param endpoint - GraphQL endpoint
225
+ * @returns Function that makes authenticated GraphQL queries
226
+ */
227
+ export function createAuthenticatedClient(
228
+ token: string,
229
+ endpoint = DEFAULT_ENDPOINT
230
+ ) {
231
+ return async function authenticatedQuery<T>(
232
+ query: string,
233
+ variables?: Record<string, unknown>
234
+ ): Promise<T> {
235
+ const response = await fetch(endpoint, {
236
+ method: "POST",
237
+ headers: {
238
+ "Content-Type": "application/json",
239
+ "x-access-token": token,
240
+ },
241
+ body: JSON.stringify({ query, variables }),
242
+ });
243
+
244
+ if (!response.ok) {
245
+ throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
246
+ }
247
+
248
+ interface GraphQLResponse<T> {
249
+ data?: T;
250
+ errors?: Array<{ message: string }>;
251
+ }
252
+
253
+ const result: GraphQLResponse<T> = await response.json();
254
+
255
+ if (result.errors && result.errors.length > 0) {
256
+ throw new Error(`GraphQL error: ${result.errors[0].message}`);
257
+ }
258
+
259
+ if (!result.data) {
260
+ throw new Error("No data returned from GraphQL query");
261
+ }
262
+
263
+ return result.data;
264
+ };
265
+ }
266
+
267
+ // ============================================================================
268
+ // Test Utilities
269
+ // ============================================================================
270
+
271
+ /**
272
+ * Get or create a test account
273
+ *
274
+ * If the wallet has never been used, it will create a new account (Signup).
275
+ * Otherwise, it will log in to the existing account (Login).
276
+ */
277
+ export async function getTestAccount(
278
+ endpoint = DEFAULT_ENDPOINT
279
+ ): Promise<WalletAuthResult> {
280
+ return walletLogin(undefined, endpoint);
281
+ }
282
+
283
+ /**
284
+ * Generate a new random wallet for testing
285
+ *
286
+ * Returns the private key and address.
287
+ * Note: The account won't exist on Matters until first login.
288
+ */
289
+ export async function generateTestWallet(): Promise<{
290
+ privateKey: string;
291
+ address: string;
292
+ }> {
293
+ try {
294
+ const { Wallet } = await import("ethers");
295
+ const wallet = Wallet.createRandom();
296
+ return {
297
+ privateKey: wallet.privateKey,
298
+ address: wallet.address,
299
+ };
300
+ } catch {
301
+ throw new Error(
302
+ "ethers.js is required. Install with: npm install --save-dev ethers"
303
+ );
304
+ }
305
+ }