@simplysm/core-browser 13.0.85 → 13.0.86

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.
@@ -4,6 +4,9 @@ export interface StoreConfig {
4
4
  }
5
5
 
6
6
  export class IndexedDbStore {
7
+ private _db: IDBDatabase | undefined;
8
+ private _opening: Promise<IDBDatabase> | undefined;
9
+
7
10
  constructor(
8
11
  private readonly _dbName: string,
9
12
  private readonly _dbVersion: number,
@@ -11,7 +14,15 @@ export class IndexedDbStore {
11
14
  ) {}
12
15
 
13
16
  async open(): Promise<IDBDatabase> {
14
- return new Promise((resolve, reject) => {
17
+ if (this._db != null) {
18
+ return this._db;
19
+ }
20
+
21
+ if (this._opening != null) {
22
+ return this._opening;
23
+ }
24
+
25
+ this._opening = new Promise<IDBDatabase>((resolve, reject) => {
15
26
  const req = indexedDB.open(this._dbName, this._dbVersion);
16
27
  req.onupgradeneeded = () => {
17
28
  const db = req.result;
@@ -21,10 +32,32 @@ export class IndexedDbStore {
21
32
  }
22
33
  }
23
34
  };
24
- req.onsuccess = () => resolve(req.result);
25
- req.onerror = () => reject(req.error);
26
- req.onblocked = () => reject(new Error("Database blocked by another connection"));
35
+ req.onsuccess = () => {
36
+ const db = req.result;
37
+ db.onversionchange = () => {
38
+ db.close();
39
+ this._db = undefined;
40
+ this._opening = undefined;
41
+ };
42
+ db.onclose = () => {
43
+ this._db = undefined;
44
+ this._opening = undefined;
45
+ };
46
+ this._db = db;
47
+ this._opening = undefined;
48
+ resolve(db);
49
+ };
50
+ req.onerror = () => {
51
+ this._opening = undefined;
52
+ reject(req.error);
53
+ };
54
+ req.onblocked = () => {
55
+ this._opening = undefined;
56
+ reject(new Error("Database blocked by another connection"));
57
+ };
27
58
  });
59
+
60
+ return this._opening;
28
61
  }
29
62
 
30
63
  async withStore<TResult>(
@@ -33,71 +66,72 @@ export class IndexedDbStore {
33
66
  fn: (store: IDBObjectStore) => Promise<TResult>,
34
67
  ): Promise<TResult> {
35
68
  const db = await this.open();
36
- try {
37
- const tx = db.transaction(storeName, mode);
38
- const store = tx.objectStore(storeName);
39
-
40
- // Await fn result first
41
- let result: TResult;
42
- let fnError: unknown;
43
- try {
44
- result = await fn(store);
45
- } catch (err) {
46
- fnError = err;
47
- tx.abort();
48
- }
69
+ const tx = db.transaction(storeName, mode);
70
+ const store = tx.objectStore(storeName);
49
71
 
50
- // Wait for transaction completion
51
- return await new Promise<TResult>((resolve, reject) => {
52
- if (fnError !== undefined) {
53
- tx.onerror = () => {
54
- db.close();
55
- reject(fnError);
56
- };
57
- } else {
58
- tx.oncomplete = () => {
59
- db.close();
60
- resolve(result!);
61
- };
62
- tx.onerror = () => {
63
- db.close();
64
- reject(tx.error);
65
- };
66
- }
67
- });
72
+ // Await fn result first
73
+ let result: TResult;
74
+ let fnError: unknown;
75
+ let hasFnError = false;
76
+ try {
77
+ result = await fn(store);
68
78
  } catch (err) {
69
- db.close();
70
- throw err;
79
+ fnError = err;
80
+ hasFnError = true;
81
+ tx.abort();
71
82
  }
83
+
84
+ // Wait for transaction completion
85
+ return new Promise<TResult>((resolve, reject) => {
86
+ if (hasFnError) {
87
+ tx.onabort = () => {
88
+ reject(fnError);
89
+ };
90
+ } else {
91
+ tx.oncomplete = () => {
92
+ resolve(result!);
93
+ };
94
+ tx.onerror = () => {
95
+ reject(tx.error);
96
+ };
97
+ }
98
+ });
72
99
  }
73
100
 
74
101
  async get<TValue>(storeName: string, key: IDBValidKey): Promise<TValue | undefined> {
75
102
  return this.withStore(storeName, "readonly", async (store) => {
76
- return new Promise((resolve, reject) => {
77
- const req = store.get(key);
78
- req.onsuccess = () => resolve(req.result as TValue | undefined);
79
- req.onerror = () => reject(req.error);
80
- });
103
+ return this._resolveRequest(store.get(key)) as Promise<TValue | undefined>;
81
104
  });
82
105
  }
83
106
 
84
107
  async put(storeName: string, value: unknown): Promise<void> {
85
- return this.withStore(storeName, "readwrite", async (store) => {
86
- return new Promise((resolve, reject) => {
87
- const req = store.put(value);
88
- req.onsuccess = () => resolve();
89
- req.onerror = () => reject(req.error);
90
- });
108
+ await this.withStore(storeName, "readwrite", async (store) => {
109
+ await this._resolveRequest(store.put(value));
110
+ });
111
+ }
112
+
113
+ async delete(storeName: string, key: IDBValidKey): Promise<void> {
114
+ await this.withStore(storeName, "readwrite", async (store) => {
115
+ await this._resolveRequest(store.delete(key));
91
116
  });
92
117
  }
93
118
 
94
119
  async getAll<TItem>(storeName: string): Promise<TItem[]> {
95
120
  return this.withStore(storeName, "readonly", async (store) => {
96
- return new Promise((resolve, reject) => {
97
- const req = store.getAll();
98
- req.onsuccess = () => resolve(req.result as TItem[]);
99
- req.onerror = () => reject(req.error);
100
- });
121
+ return this._resolveRequest(store.getAll()) as Promise<TItem[]>;
101
122
  });
102
123
  }
124
+
125
+ private _resolveRequest<T>(req: IDBRequest<T>): Promise<T> {
126
+ return new Promise((resolve, reject) => {
127
+ req.onsuccess = () => resolve(req.result);
128
+ req.onerror = () => reject(req.error);
129
+ });
130
+ }
131
+
132
+ close(): void {
133
+ this._db?.close();
134
+ this._db = undefined;
135
+ this._opening = undefined;
136
+ }
103
137
  }
@@ -23,7 +23,7 @@ export class IndexedDbVirtualFs {
23
23
  async deleteByPrefix(keyPrefix: string): Promise<boolean> {
24
24
  return this._db.withStore(this._storeName, "readwrite", async (store) => {
25
25
  return new Promise((resolve, reject) => {
26
- const req = store.openCursor();
26
+ const req = store.openCursor(IDBKeyRange.bound(keyPrefix, keyPrefix + "\uffff"));
27
27
  let found = false;
28
28
  req.onsuccess = () => {
29
29
  const cursor = req.result;
@@ -46,7 +46,7 @@ export class IndexedDbVirtualFs {
46
46
  async listChildren(prefix: string): Promise<{ name: string; isDirectory: boolean }[]> {
47
47
  return this._db.withStore(this._storeName, "readonly", async (store) => {
48
48
  return new Promise((resolve, reject) => {
49
- const req = store.openCursor();
49
+ const req = store.openCursor(IDBKeyRange.bound(prefix, prefix + "\uffff"));
50
50
  const map = new Map<string, boolean>();
51
51
  req.onsuccess = () => {
52
52
  const cursor = req.result;
@@ -152,6 +152,40 @@ describe("Element prototype extensions", () => {
152
152
  });
153
153
  });
154
154
 
155
+ describe("findFirstFocusableChild", () => {
156
+ it("returns first focusable child element", () => {
157
+ container.innerHTML = `<span>text</span><button id="btn">click</button>`;
158
+
159
+ const result = container.findFirstFocusableChild();
160
+
161
+ expect(result?.id).toBe("btn");
162
+ });
163
+
164
+ it("returns undefined when no focusable child exists", () => {
165
+ container.innerHTML = `<span>text</span><div>no focusable</div>`;
166
+
167
+ const result = container.findFirstFocusableChild();
168
+
169
+ expect(result).toBeUndefined();
170
+ });
171
+
172
+ it("returns depth-first order element", () => {
173
+ container.innerHTML = `<div><button id="deep">deep</button></div><button id="shallow">shallow</button>`;
174
+
175
+ const result = container.findFirstFocusableChild();
176
+
177
+ expect(result?.id).toBe("deep");
178
+ });
179
+
180
+ it("recognizes tabindex attribute as focusable", () => {
181
+ container.innerHTML = `<div tabindex="0" id="tab">focusable</div>`;
182
+
183
+ const result = container.findFirstFocusableChild();
184
+
185
+ expect(result?.id).toBe("tab");
186
+ });
187
+ });
188
+
155
189
  describe("isOffsetElement", () => {
156
190
  it("position: relative is an offset element", () => {
157
191
  container.style.position = "relative";
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { IndexedDbStore } from "../../src/utils/IndexedDbStore";
3
+
4
+ let dbCounter = 0;
5
+ function uniqueDbName() {
6
+ return `test_db_${Date.now()}_${dbCounter++}`;
7
+ }
8
+
9
+ const STORE_NAME = "items";
10
+ const KEY_PATH = "id";
11
+
12
+ function createStore(dbName?: string) {
13
+ return new IndexedDbStore(dbName ?? uniqueDbName(), 1, [
14
+ { name: STORE_NAME, keyPath: KEY_PATH },
15
+ ]);
16
+ }
17
+
18
+ describe("IndexedDbStore", () => {
19
+ let store: IndexedDbStore;
20
+
21
+ afterEach(() => {
22
+ store.close();
23
+ });
24
+
25
+ it("put/get: 항목 저장 후 조회하면 값이 일치한다", async () => {
26
+ store = createStore();
27
+ const item = { id: "key1", value: "hello" };
28
+
29
+ await store.put(STORE_NAME, item);
30
+ const result = await store.get<{ id: string; value: string }>(STORE_NAME, "key1");
31
+
32
+ expect(result).toEqual(item);
33
+ });
34
+
35
+ it("getAll: 여러 항목 저장 후 전체 조회하면 배열 크기 및 내용이 일치한다", async () => {
36
+ store = createStore();
37
+ const items = [
38
+ { id: "a", value: 1 },
39
+ { id: "b", value: 2 },
40
+ { id: "c", value: 3 },
41
+ ];
42
+
43
+ for (const item of items) {
44
+ await store.put(STORE_NAME, item);
45
+ }
46
+
47
+ const result = await store.getAll<{ id: string; value: number }>(STORE_NAME);
48
+
49
+ expect(result).toHaveLength(3);
50
+ expect(result).toEqual(expect.arrayContaining(items));
51
+ });
52
+
53
+ it("delete: 항목 저장 후 삭제하면 get으로 undefined가 반환된다", async () => {
54
+ store = createStore();
55
+ await store.put(STORE_NAME, { id: "del1", value: "to-delete" });
56
+
57
+ await store.delete(STORE_NAME, "del1");
58
+
59
+ const result = await store.get(STORE_NAME, "del1");
60
+ expect(result).toBeUndefined();
61
+ });
62
+
63
+ it("존재하지 않는 키 get: undefined를 반환한다", async () => {
64
+ store = createStore();
65
+
66
+ const result = await store.get(STORE_NAME, "nonexistent");
67
+
68
+ expect(result).toBeUndefined();
69
+ });
70
+
71
+ it("커넥션 캐싱: 연속 호출 시 동일 DB 커넥션을 재사용한다", async () => {
72
+ store = createStore();
73
+
74
+ await store.put(STORE_NAME, { id: "cache1", value: "first" });
75
+ const result1 = await store.get<{ id: string; value: string }>(STORE_NAME, "cache1");
76
+
77
+ await store.put(STORE_NAME, { id: "cache2", value: "second" });
78
+ const result2 = await store.get<{ id: string; value: string }>(STORE_NAME, "cache2");
79
+
80
+ expect(result1).toEqual({ id: "cache1", value: "first" });
81
+ expect(result2).toEqual({ id: "cache2", value: "second" });
82
+ });
83
+
84
+ it("abort 처리: withStore 콜백에서 에러 발생 시 Promise가 reject된다", async () => {
85
+ store = createStore();
86
+
87
+ await expect(
88
+ store.withStore(STORE_NAME, "readwrite", () => {
89
+ throw new Error("intentional error");
90
+ }),
91
+ ).rejects.toThrow("intentional error");
92
+ }, 5000);
93
+
94
+ it("close 후 재연결: close() 호출 후 get 호출 시 자동으로 재연결되어 정상 동작한다", async () => {
95
+ store = createStore();
96
+
97
+ await store.put(STORE_NAME, { id: "reconnect", value: "data" });
98
+ store.close();
99
+
100
+ const result = await store.get<{ id: string; value: string }>(STORE_NAME, "reconnect");
101
+ expect(result).toEqual({ id: "reconnect", value: "data" });
102
+ });
103
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { IndexedDbStore } from "../../src/utils/IndexedDbStore";
3
+ import { IndexedDbVirtualFs } from "../../src/utils/IndexedDbVirtualFs";
4
+
5
+ let dbCounter = 0;
6
+ function uniqueDbName() {
7
+ return `test_vfs_${Date.now()}_${dbCounter++}`;
8
+ }
9
+
10
+ const STORE_NAME = "files";
11
+ const KEY_FIELD = "path";
12
+
13
+ function createVfs(dbName?: string) {
14
+ const store = new IndexedDbStore(dbName ?? uniqueDbName(), 1, [
15
+ { name: STORE_NAME, keyPath: KEY_FIELD },
16
+ ]);
17
+ const vfs = new IndexedDbVirtualFs(store, STORE_NAME, KEY_FIELD);
18
+ return { store, vfs };
19
+ }
20
+
21
+ describe("IndexedDbVirtualFs", () => {
22
+ let store: IndexedDbStore;
23
+
24
+ afterEach(() => {
25
+ store.close();
26
+ });
27
+
28
+ it("putEntry + getEntry (file): 파일 저장 후 조회하면 kind와 dataBase64가 일치한다", async () => {
29
+ const ctx = createVfs();
30
+ store = ctx.store;
31
+
32
+ await ctx.vfs.putEntry("/a/file.txt", "file", btoa("hello"));
33
+ const entry = await ctx.vfs.getEntry("/a/file.txt");
34
+
35
+ expect(entry).toMatchObject({ kind: "file", dataBase64: btoa("hello") });
36
+ });
37
+
38
+ it("putEntry + getEntry (dir): 디렉터리 저장 후 조회하면 kind가 dir이다", async () => {
39
+ const ctx = createVfs();
40
+ store = ctx.store;
41
+
42
+ await ctx.vfs.putEntry("/a", "dir");
43
+ const entry = await ctx.vfs.getEntry("/a");
44
+
45
+ expect(entry).toMatchObject({ kind: "dir" });
46
+ });
47
+
48
+ it("getEntry 미존재: 존재하지 않는 키를 조회하면 undefined를 반환한다", async () => {
49
+ const ctx = createVfs();
50
+ store = ctx.store;
51
+
52
+ const entry = await ctx.vfs.getEntry("/nonexist");
53
+
54
+ expect(entry).toBeUndefined();
55
+ });
56
+
57
+ it("deleteByPrefix - prefix 자체 삭제: prefix와 정확히 일치하는 항목이 삭제된다", async () => {
58
+ const ctx = createVfs();
59
+ store = ctx.store;
60
+
61
+ await ctx.vfs.putEntry("/a/b", "dir");
62
+
63
+ await ctx.vfs.deleteByPrefix("/a/b");
64
+
65
+ const entry = await ctx.vfs.getEntry("/a/b");
66
+ expect(entry).toBeUndefined();
67
+ });
68
+
69
+ it("deleteByPrefix - 하위 항목 삭제: prefix 하위의 모든 항목이 삭제된다", async () => {
70
+ const ctx = createVfs();
71
+ store = ctx.store;
72
+
73
+ await ctx.vfs.putEntry("/a/b", "dir");
74
+ await ctx.vfs.putEntry("/a/b/c", "file", btoa("c"));
75
+ await ctx.vfs.putEntry("/a/b/d", "file", btoa("d"));
76
+
77
+ await ctx.vfs.deleteByPrefix("/a/b");
78
+
79
+ expect(await ctx.vfs.getEntry("/a/b")).toBeUndefined();
80
+ expect(await ctx.vfs.getEntry("/a/b/c")).toBeUndefined();
81
+ expect(await ctx.vfs.getEntry("/a/b/d")).toBeUndefined();
82
+ });
83
+
84
+ it("deleteByPrefix - 무관한 항목 유지: prefix와 무관한 항목은 삭제되지 않는다", async () => {
85
+ const ctx = createVfs();
86
+ store = ctx.store;
87
+
88
+ await ctx.vfs.putEntry("/a/b", "dir");
89
+ await ctx.vfs.putEntry("/a/x", "file", btoa("x"));
90
+
91
+ await ctx.vfs.deleteByPrefix("/a/b");
92
+
93
+ const entry = await ctx.vfs.getEntry("/a/x");
94
+ expect(entry).toMatchObject({ kind: "file", dataBase64: btoa("x") });
95
+ });
96
+
97
+ it("deleteByPrefix - 반환값: 삭제 대상이 있으면 true, 없으면 false를 반환한다", async () => {
98
+ const ctx = createVfs();
99
+ store = ctx.store;
100
+
101
+ await ctx.vfs.putEntry("/a/b", "dir");
102
+
103
+ const resultTrue = await ctx.vfs.deleteByPrefix("/a/b");
104
+ expect(resultTrue).toBe(true);
105
+
106
+ const resultFalse = await ctx.vfs.deleteByPrefix("/nonexist");
107
+ expect(resultFalse).toBe(false);
108
+ });
109
+
110
+ it("listChildren - 직접 자식 목록: prefix의 직접 자식만 반환한다", async () => {
111
+ const ctx = createVfs();
112
+ store = ctx.store;
113
+
114
+ await ctx.vfs.putEntry("/root/a", "file", btoa("a"));
115
+ await ctx.vfs.putEntry("/root/b", "dir");
116
+ await ctx.vfs.putEntry("/root/b/c", "file", btoa("c"));
117
+
118
+ const children = await ctx.vfs.listChildren("/root/");
119
+
120
+ expect(children).toHaveLength(2);
121
+ const names = children.map((c) => c.name).sort();
122
+ expect(names).toEqual(["a", "b"]);
123
+ });
124
+
125
+ it("listChildren - isDirectory 판별: 파일과 디렉터리를 올바르게 구분한다", async () => {
126
+ const ctx = createVfs();
127
+ store = ctx.store;
128
+
129
+ await ctx.vfs.putEntry("/root/a", "file", btoa("a"));
130
+ await ctx.vfs.putEntry("/root/b", "dir");
131
+ await ctx.vfs.putEntry("/root/b/c", "file", btoa("c"));
132
+
133
+ const children = await ctx.vfs.listChildren("/root/");
134
+
135
+ const fileChild = children.find((c) => c.name === "a");
136
+ const dirChild = children.find((c) => c.name === "b");
137
+ expect(fileChild?.isDirectory).toBe(false);
138
+ expect(dirChild?.isDirectory).toBe(true);
139
+ });
140
+
141
+ it("ensureDir - 중첩 경로 생성: 중간 디렉터리가 모두 생성된다", async () => {
142
+ const ctx = createVfs();
143
+ store = ctx.store;
144
+
145
+ await ctx.vfs.ensureDir((p) => p, "/a/b/c");
146
+
147
+ expect(await ctx.vfs.getEntry("/a")).toMatchObject({ kind: "dir" });
148
+ expect(await ctx.vfs.getEntry("/a/b")).toMatchObject({ kind: "dir" });
149
+ expect(await ctx.vfs.getEntry("/a/b/c")).toMatchObject({ kind: "dir" });
150
+ });
151
+
152
+ it("ensureDir - 멱등성: 같은 경로로 두 번 호출해도 에러가 발생하지 않는다", async () => {
153
+ const ctx = createVfs();
154
+ store = ctx.store;
155
+
156
+ await ctx.vfs.ensureDir((p) => p, "/a/b/c");
157
+ await ctx.vfs.ensureDir((p) => p, "/a/b/c");
158
+
159
+ expect(await ctx.vfs.getEntry("/a/b/c")).toMatchObject({ kind: "dir" });
160
+ });
161
+
162
+ it("ensureDir - 루트: 루트 경로를 생성하면 getEntry로 조회 가능하다", async () => {
163
+ const ctx = createVfs();
164
+ store = ctx.store;
165
+
166
+ await ctx.vfs.ensureDir((p) => p, "/");
167
+
168
+ const entry = await ctx.vfs.getEntry("/");
169
+ expect(entry).toMatchObject({ kind: "dir" });
170
+ });
171
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { fetchUrlBytes } from "../../src/utils/fetch";
3
+
4
+ function createMockReader(chunks: Uint8Array[]) {
5
+ let index = 0;
6
+ const releaseLock = vi.fn();
7
+ const reader = {
8
+ read: vi.fn(() => {
9
+ if (index < chunks.length) {
10
+ return Promise.resolve({ done: false as const, value: chunks[index++] });
11
+ }
12
+ return Promise.resolve({ done: true as const, value: undefined });
13
+ }),
14
+ releaseLock,
15
+ };
16
+ return { reader, releaseLock };
17
+ }
18
+
19
+ function createMockResponse(options: {
20
+ ok?: boolean;
21
+ status?: number;
22
+ statusText?: string;
23
+ contentLength?: number | null;
24
+ chunks?: Uint8Array[];
25
+ bodyNull?: boolean;
26
+ }) {
27
+ const { reader, releaseLock } = options.bodyNull
28
+ ? { reader: undefined, releaseLock: vi.fn() }
29
+ : createMockReader(options.chunks ?? []);
30
+
31
+ return {
32
+ response: {
33
+ ok: options.ok ?? true,
34
+ status: options.status ?? 200,
35
+ statusText: options.statusText ?? "OK",
36
+ headers: {
37
+ get: (name: string) => {
38
+ if (name === "Content-Length" && options.contentLength != null) {
39
+ return String(options.contentLength);
40
+ }
41
+ return null;
42
+ },
43
+ },
44
+ body: options.bodyNull ? null : { getReader: () => reader },
45
+ } as unknown as Response,
46
+ releaseLock,
47
+ };
48
+ }
49
+
50
+ describe("fetchUrlBytes", () => {
51
+ afterEach(() => {
52
+ vi.unstubAllGlobals();
53
+ });
54
+
55
+ it("downloads binary data when Content-Length is present", async () => {
56
+ const chunk1 = new Uint8Array([1, 2, 3, 4, 5]);
57
+ const chunk2 = new Uint8Array([6, 7, 8, 9, 10]);
58
+ const { response } = createMockResponse({
59
+ contentLength: 10,
60
+ chunks: [chunk1, chunk2],
61
+ });
62
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
63
+
64
+ const result = await fetchUrlBytes("https://example.com/file.bin");
65
+
66
+ expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
67
+ });
68
+
69
+ it("calls onProgress callback for each chunk", async () => {
70
+ const chunk1 = new Uint8Array([1, 2, 3, 4, 5]);
71
+ const chunk2 = new Uint8Array([6, 7, 8, 9, 10]);
72
+ const { response } = createMockResponse({
73
+ contentLength: 10,
74
+ chunks: [chunk1, chunk2],
75
+ });
76
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
77
+
78
+ const onProgress = vi.fn();
79
+ await fetchUrlBytes("https://example.com/file.bin", { onProgress });
80
+
81
+ expect(onProgress).toHaveBeenCalledTimes(2);
82
+ expect(onProgress).toHaveBeenNthCalledWith(1, { receivedLength: 5, contentLength: 10 });
83
+ expect(onProgress).toHaveBeenNthCalledWith(2, { receivedLength: 10, contentLength: 10 });
84
+ });
85
+
86
+ it("downloads binary data when Content-Length is absent", async () => {
87
+ const chunk1 = new Uint8Array([10, 20]);
88
+ const chunk2 = new Uint8Array([30, 40, 50]);
89
+ const { response } = createMockResponse({
90
+ contentLength: null,
91
+ chunks: [chunk1, chunk2],
92
+ });
93
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
94
+
95
+ const result = await fetchUrlBytes("https://example.com/stream");
96
+
97
+ expect(result).toEqual(new Uint8Array([10, 20, 30, 40, 50]));
98
+ });
99
+
100
+ it("throws on HTTP error response", async () => {
101
+ const { response } = createMockResponse({
102
+ ok: false,
103
+ status: 404,
104
+ statusText: "Not Found",
105
+ });
106
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
107
+
108
+ await expect(fetchUrlBytes("https://example.com/missing")).rejects.toThrow(
109
+ "Download failed: 404 Not Found",
110
+ );
111
+ });
112
+
113
+ it("throws when response body is null", async () => {
114
+ const { response } = createMockResponse({ bodyNull: true });
115
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
116
+
117
+ await expect(fetchUrlBytes("https://example.com/nobody")).rejects.toThrow(
118
+ "Response body is not readable",
119
+ );
120
+ });
121
+
122
+ it("calls releaseLock after successful download", async () => {
123
+ const { response, releaseLock } = createMockResponse({
124
+ contentLength: 3,
125
+ chunks: [new Uint8Array([1, 2, 3])],
126
+ });
127
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
128
+
129
+ await fetchUrlBytes("https://example.com/file.bin");
130
+
131
+ expect(releaseLock).toHaveBeenCalledOnce();
132
+ });
133
+
134
+ it("calls releaseLock even when reader.read throws", async () => {
135
+ const releaseLock = vi.fn();
136
+ const reader = {
137
+ read: vi.fn().mockRejectedValue(new Error("Network error")),
138
+ releaseLock,
139
+ };
140
+ const response = {
141
+ ok: true,
142
+ status: 200,
143
+ statusText: "OK",
144
+ headers: {
145
+ get: () => null,
146
+ },
147
+ body: { getReader: () => reader },
148
+ } as unknown as Response;
149
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
150
+
151
+ await expect(fetchUrlBytes("https://example.com/fail")).rejects.toThrow("Network error");
152
+ expect(releaseLock).toHaveBeenCalledOnce();
153
+ });
154
+ });