Skip to content

Commit bfd31b4

Browse files
authored
feat(nodejs): add folder API support and live e2e coverage (#52)
Extend the Node.js client with user and team folder APIs, folder-aware note options, and exported folder types. Add an opt-in live e2e test suite and keep non-idempotent retries from masking server-side POST failures. Made-with: Cursor
1 parent 9de781f commit bfd31b4

9 files changed

Lines changed: 605 additions & 8 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Optional live API checks. Add repository secret HACKMD_E2E_ACCESS_TOKEN.
2+
# Optionally add HACKMD_E2E_API_ENDPOINT (e.g. https://api-stage.hackmd.io/v1); otherwise production is used.
3+
4+
name: E2E (live HackMD API)
5+
6+
on:
7+
workflow_dispatch:
8+
9+
jobs:
10+
e2e:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Set up Node.js
16+
uses: actions/setup-node@v4
17+
with:
18+
node-version: '20'
19+
cache: 'npm'
20+
cache-dependency-path: nodejs/package-lock.json
21+
22+
- name: Install dependencies
23+
working-directory: nodejs
24+
run: npm ci
25+
26+
- name: Run e2e tests
27+
working-directory: nodejs
28+
env:
29+
HACKMD_ACCESS_TOKEN: ${{ secrets.HACKMD_E2E_ACCESS_TOKEN }}
30+
HACKMD_API_ENDPOINT: ${{ secrets.HACKMD_E2E_API_ENDPOINT }}
31+
run: |
32+
if [ -z "${HACKMD_ACCESS_TOKEN:-}" ]; then
33+
echo "::error::Add repository secret HACKMD_E2E_ACCESS_TOKEN (a valid API token for the target environment)."
34+
exit 1
35+
fi
36+
npm run test:e2e

nodejs/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,39 @@ const updatedNote = await client.getNote('note-id', { etag })
121121

122122
See the [code](./src/index.ts) and [typings](./src/type.ts). The API client is written in TypeScript, so you can get auto-completion and type checking in any TypeScript Language Server powered editor or IDE.
123123

124+
## E2E tests (live API)
125+
126+
Integration tests call a real HackMD API (staging or production). They are **not** run by `npm test` or the default CI job.
127+
128+
**Requirements**
129+
130+
- `HACKMD_ACCESS_TOKEN` — a valid personal access token for the environment you target.
131+
- Optional: `HACKMD_API_ENDPOINT` — defaults to `https://api.hackmd.io/v1`. For staging, use `https://api-stage.hackmd.io/v1`.
132+
133+
**Read-only (default e2e)**
134+
135+
```bash
136+
cd nodejs
137+
export HACKMD_ACCESS_TOKEN=your_token
138+
export HACKMD_API_ENDPOINT=https://api-stage.hackmd.io/v1 # optional
139+
npm run test:e2e
140+
```
141+
142+
**With CRUD / mutations**
143+
144+
Set `HACKMD_E2E_MUTATIONS=1` to run write tests against your account:
145+
146+
- **Notes:** create → get → update (title, content, tags) → list → delete.
147+
- **Folders:** one integration test runs create (root + nested) → get → update → list → folder-order round-trip (skipped if that API returns 404) → delete. If **POST `/folders`** returns 404 (common before full production rollout), the test exits early with a warning; use staging or `HACKMD_E2E_FOLDERS=0`.
148+
149+
```bash
150+
HACKMD_E2E_MUTATIONS=1 npm run test:e2e
151+
```
152+
153+
Folder CRUD touches folder display order briefly, then restores the previous order in an `afterAll` hook. To skip folder mutations (e.g. production without `/folders`), set `HACKMD_E2E_FOLDERS=0`.
154+
155+
The read-only `getFolderList` test still treats HTTP 404 as “folders not available on this host yet” and passes without failing the suite.
156+
124157
## License
125158

126159
MIT

nodejs/jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const customJestConfig: JestConfigWithTsJest = {
66
transformIgnorePatterns: ["<rootDir>/node_modules/"],
77
extensionsToTreatAsEsm: [".ts"],
88
setupFiles: ["dotenv/config"],
9+
testPathIgnorePatterns: ["/node_modules/", "<rootDir>/tests/e2e/"],
910
}
1011

1112
export default customJestConfig

nodejs/jest.e2e.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { JestConfigWithTsJest } from "ts-jest"
2+
3+
/** Live API tests; run with `npm run test:e2e` (see nodejs/README.md). */
4+
const e2eJestConfig: JestConfigWithTsJest = {
5+
preset: "ts-jest",
6+
testEnvironment: "node",
7+
transformIgnorePatterns: ["<rootDir>/node_modules/"],
8+
extensionsToTreatAsEsm: [".ts"],
9+
setupFiles: ["dotenv/config"],
10+
testMatch: ["<rootDir>/tests/e2e/**/*.spec.ts"],
11+
testTimeout: 60_000,
12+
}
13+
14+
export default e2eJestConfig

nodejs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"watch": "npm run clean && rollup -c -w",
2626
"prepublishOnly": "npm run build",
2727
"lint": "eslint src --fix --ext .ts",
28-
"test": "jest"
28+
"test": "jest",
29+
"test:e2e": "jest --config jest.e2e.config.ts"
2930
},
3031
"keywords": [
3132
"HackMD",

nodejs/src/index.ts

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
2-
import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote, UpdateNoteOptions } from './type'
2+
import {
3+
CreateNoteOptions,
4+
CreateTeamFolderBody,
5+
CreateUserFolderBody,
6+
GetMe,
7+
GetUserHistory,
8+
GetUserNotes,
9+
GetUserNote,
10+
CreateUserNote,
11+
GetUserTeams,
12+
GetTeamNotes,
13+
CreateTeamNote,
14+
SingleNote,
15+
UpdateNoteOptions,
16+
GetFolders,
17+
GetFolder,
18+
GetFolderOrder,
19+
CreateFolderResult,
20+
UpdateFolderResult,
21+
GetTeamFolders,
22+
GetTeamFolder,
23+
GetTeamFolderOrder,
24+
CreateTeamFolderResult,
25+
UpdateTeamFolderResult,
26+
UpdateFolderOrderBody,
27+
UpdateTeamFolderBody,
28+
UpdateUserFolderBody,
29+
} from './type'
330
import * as HackMDErrors from './error'
431

532
export type RequestOptions = {
@@ -59,6 +86,10 @@ export class API {
5986
}
6087
)
6188

89+
if (options.retryConfig) {
90+
this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay)
91+
}
92+
6293
if (options.wrapResponseErrors) {
6394
this.axios.interceptors.response.use(
6495
(response: AxiosResponse) => {
@@ -94,16 +125,21 @@ export class API {
94125
}
95126
)
96127
}
97-
if (options.retryConfig) {
98-
this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay)
99-
}
100128
}
101129

102130
private exponentialBackoff (retries: number, baseDelay: number): number {
103131
return Math.pow(2, retries) * baseDelay
104132
}
105133

106-
private isRetryableError (error: AxiosError): boolean {
134+
private isRetryableMethod (method?: string): boolean {
135+
if (!method) return false
136+
const normalized = method.toLowerCase()
137+
return ['get', 'head', 'options', 'put', 'delete'].includes(normalized)
138+
}
139+
140+
private isRetryableError (error: unknown): boolean {
141+
if (!axios.isAxiosError(error)) return false
142+
if (!this.isRetryableMethod(error.config?.method)) return false
107143
return (
108144
!error.response ||
109145
(error.response.status >= 500 && error.response.status < 600) ||
@@ -197,6 +233,62 @@ export class API {
197233
return this.axios.delete<AxiosResponse>(`teams/${teamPath}/notes/${noteId}`)
198234
}
199235

236+
async getFolderList<Opt extends RequestOptions> (options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetFolders>> {
237+
return this.unwrapData(this.axios.get<GetFolders>('folders'), options.unwrapData) as unknown as OptionReturnType<Opt, GetFolders>
238+
}
239+
240+
async createFolder<Opt extends RequestOptions> (payload: CreateUserFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, CreateFolderResult>> {
241+
return this.unwrapData(this.axios.post<CreateFolderResult>('folders', payload), options.unwrapData) as unknown as OptionReturnType<Opt, CreateFolderResult>
242+
}
243+
244+
async getFolder<Opt extends RequestOptions> (folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetFolder>> {
245+
return this.unwrapData(this.axios.get<GetFolder>(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, GetFolder>
246+
}
247+
248+
async updateFolder<Opt extends RequestOptions> (folderId: string, payload: UpdateUserFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateFolderResult>> {
249+
return this.unwrapData(this.axios.patch<UpdateFolderResult>(`folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateFolderResult>
250+
}
251+
252+
async deleteFolder<Opt extends RequestOptions> (folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, void>> {
253+
return this.unwrapData(this.axios.delete(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, void>
254+
}
255+
256+
async getFolderOrder<Opt extends RequestOptions> (options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetFolderOrder>> {
257+
return this.unwrapData(this.axios.get<GetFolderOrder>('folders/folder-order'), options.unwrapData) as unknown as OptionReturnType<Opt, GetFolderOrder>
258+
}
259+
260+
async updateFolderOrder<Opt extends RequestOptions> (payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateFolderResult>> {
261+
return this.unwrapData(this.axios.put<UpdateFolderResult>('folders/folder-order', payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateFolderResult>
262+
}
263+
264+
async getTeamFolderList<Opt extends RequestOptions> (teamPath: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetTeamFolders>> {
265+
return this.unwrapData(this.axios.get<GetTeamFolders>(`teams/${teamPath}/folders`), options.unwrapData) as unknown as OptionReturnType<Opt, GetTeamFolders>
266+
}
267+
268+
async createTeamFolder<Opt extends RequestOptions> (teamPath: string, payload: CreateTeamFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, CreateTeamFolderResult>> {
269+
return this.unwrapData(this.axios.post<CreateTeamFolderResult>(`teams/${teamPath}/folders`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, CreateTeamFolderResult>
270+
}
271+
272+
async getTeamFolder<Opt extends RequestOptions> (teamPath: string, folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetTeamFolder>> {
273+
return this.unwrapData(this.axios.get<GetTeamFolder>(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, GetTeamFolder>
274+
}
275+
276+
async updateTeamFolder<Opt extends RequestOptions> (teamPath: string, folderId: string, payload: UpdateTeamFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateTeamFolderResult>> {
277+
return this.unwrapData(this.axios.patch<UpdateTeamFolderResult>(`teams/${teamPath}/folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateTeamFolderResult>
278+
}
279+
280+
async deleteTeamFolder<Opt extends RequestOptions> (teamPath: string, folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, void>> {
281+
return this.unwrapData(this.axios.delete(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, void>
282+
}
283+
284+
async getTeamFolderOrder<Opt extends RequestOptions> (teamPath: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetTeamFolderOrder>> {
285+
return this.unwrapData(this.axios.get<GetTeamFolderOrder>(`teams/${teamPath}/folders/folder-order`), options.unwrapData) as unknown as OptionReturnType<Opt, GetTeamFolderOrder>
286+
}
287+
288+
async updateTeamFolderOrder<Opt extends RequestOptions> (teamPath: string, payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateTeamFolderResult>> {
289+
return this.unwrapData(this.axios.put<UpdateTeamFolderResult>(`teams/${teamPath}/folders/folder-order`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateTeamFolderResult>
290+
}
291+
200292
private unwrapData<T> (reqP: Promise<AxiosResponse<T>>, unwrap = true, includeEtag = false) {
201293
if (!unwrap) {
202294
// For raw responses, etag is available via response.headers
@@ -211,4 +303,6 @@ export class API {
211303
}
212304
}
213305

306+
export * from './type'
307+
214308
export default API

nodejs/src/type.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ export enum CommentPermissionType {
2121
export type CreateNoteOptions = {
2222
title?: string
2323
content?: string
24+
description?: string
25+
tags?: string[]
2426
readPermission?: NotePermissionRole,
2527
writePermission?: NotePermissionRole,
2628
commentPermission?: CommentPermissionType,
2729
permalink?: string
30+
parentFolderId?: string
2831
}
2932

3033
export type Team = {
@@ -62,6 +65,16 @@ export enum NotePermissionRole {
6265
GUEST = 'guest'
6366
}
6467

68+
/** Folder breadcrumb segment as returned on notes (OpenAPI `FolderPath`). */
69+
export type FolderPath = {
70+
id: string
71+
name: string
72+
icon: string | null
73+
color: string | null
74+
parentId: string | null
75+
clientId: string
76+
}
77+
6578
export type Note = {
6679
id: string
6780
title: string
@@ -79,13 +92,16 @@ export type Note = {
7992

8093
readPermission: NotePermissionRole
8194
writePermission: NotePermissionRole
95+
folderPaths?: FolderPath[]
8296
}
8397

8498
export type SingleNote = Note & {
8599
content: string
86100
}
87101

88-
export type UpdateNoteOptions = Partial<Pick<SingleNote, 'content' | 'title' | 'tags' | 'readPermission' | 'writePermission' | 'permalink'>>
102+
export type UpdateNoteOptions = Partial<Pick<SingleNote, 'content' | 'title' | 'tags' | 'readPermission' | 'writePermission' | 'permalink'>> & {
103+
parentFolderId?: string
104+
}
89105

90106
// User
91107
export type GetMe = User
@@ -107,3 +123,55 @@ export type CreateTeamNote = SingleNote
107123
export type UpdateTeamNote = void
108124
export type DeleteTeamNote = void
109125

126+
// Folders (user & team workspaces)
127+
export type ApiFolder = {
128+
id: string
129+
name: string
130+
description: string | null
131+
icon: string | null
132+
color: string | null
133+
parentFolderId: string | null
134+
createdAt: number
135+
updatedAt: number
136+
}
137+
138+
/** Maps each parent folder id or the literal `root` to ordered child folder ids. */
139+
export type ApiFolderOrder = Record<string, string[]>
140+
141+
export type CreateUserFolderBody = {
142+
name?: string
143+
description?: string
144+
icon?: string
145+
color?: string
146+
parentFolderId?: string
147+
}
148+
149+
export type UpdateUserFolderBody = {
150+
name?: string
151+
description?: string | null
152+
icon?: string | null
153+
color?: string | null
154+
parentFolderId?: string | null
155+
}
156+
157+
export type CreateTeamFolderBody = CreateUserFolderBody
158+
159+
export type UpdateTeamFolderBody = UpdateUserFolderBody
160+
161+
export type UpdateFolderOrderBody = {
162+
order: ApiFolderOrder
163+
}
164+
165+
export type GetFolders = ApiFolder[]
166+
export type GetTeamFolders = ApiFolder[]
167+
export type GetFolder = ApiFolder
168+
export type GetTeamFolder = ApiFolder
169+
export type CreateFolderResult = ApiFolder
170+
export type CreateTeamFolderResult = ApiFolder
171+
export type UpdateFolderResult = ApiFolder
172+
export type UpdateTeamFolderResult = ApiFolder
173+
export type DeleteFolderResult = void
174+
export type DeleteTeamFolderResult = void
175+
export type GetFolderOrder = ApiFolderOrder
176+
export type GetTeamFolderOrder = ApiFolderOrder
177+

0 commit comments

Comments
 (0)