@simplysm/core-browser 13.0.99 → 14.0.1

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 (42) hide show
  1. package/dist/extensions/element-ext.d.ts +36 -36
  2. package/dist/extensions/element-ext.d.ts.map +1 -1
  3. package/dist/extensions/element-ext.js +132 -111
  4. package/dist/extensions/element-ext.js.map +1 -6
  5. package/dist/extensions/html-element-ext.d.ts +22 -22
  6. package/dist/extensions/html-element-ext.js +50 -45
  7. package/dist/extensions/html-element-ext.js.map +1 -6
  8. package/dist/index.js +4 -1
  9. package/dist/index.js.map +1 -6
  10. package/dist/utils/IndexedDbStore.js +115 -112
  11. package/dist/utils/IndexedDbStore.js.map +1 -6
  12. package/dist/utils/IndexedDbVirtualFs.js +81 -83
  13. package/dist/utils/IndexedDbVirtualFs.js.map +1 -6
  14. package/dist/utils/download.d.ts +3 -3
  15. package/dist/utils/download.js +18 -14
  16. package/dist/utils/download.js.map +1 -6
  17. package/dist/utils/fetch.d.ts +1 -1
  18. package/dist/utils/fetch.d.ts.map +1 -1
  19. package/dist/utils/fetch.js +46 -36
  20. package/dist/utils/fetch.js.map +1 -6
  21. package/dist/utils/file-dialog.d.ts +1 -1
  22. package/dist/utils/file-dialog.js +19 -19
  23. package/dist/utils/file-dialog.js.map +1 -6
  24. package/package.json +7 -10
  25. package/src/extensions/element-ext.ts +40 -40
  26. package/src/extensions/html-element-ext.ts +24 -24
  27. package/src/index.ts +3 -3
  28. package/src/utils/IndexedDbStore.ts +3 -3
  29. package/src/utils/download.ts +3 -3
  30. package/src/utils/fetch.ts +17 -5
  31. package/src/utils/file-dialog.ts +1 -1
  32. package/README.md +0 -106
  33. package/docs/classes.md +0 -184
  34. package/docs/element-extensions.md +0 -134
  35. package/docs/html-element-extensions.md +0 -56
  36. package/docs/utilities.md +0 -71
  37. package/tests/extensions/element-ext.spec.ts +0 -693
  38. package/tests/extensions/html-element-ext.spec.ts +0 -175
  39. package/tests/utils/IndexedDbStore.spec.ts +0 -103
  40. package/tests/utils/IndexedDbVirtualFs.spec.ts +0 -171
  41. package/tests/utils/download.spec.ts +0 -66
  42. package/tests/utils/fetch.spec.ts +0 -154
@@ -1,175 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { ArgumentError } from "@simplysm/core-common";
3
- import "../../src/extensions/html-element-ext";
4
-
5
- describe("HTMLElement prototype extensions", () => {
6
- let container: HTMLDivElement;
7
-
8
- beforeEach(() => {
9
- container = document.createElement("div");
10
- document.body.appendChild(container);
11
- });
12
-
13
- afterEach(() => {
14
- container.remove();
15
- });
16
-
17
- describe("repaint", () => {
18
- it("triggers reflow by accessing offsetHeight", () => {
19
- const el = document.createElement("div");
20
- const offsetHeightSpy = vi.spyOn(el, "offsetHeight", "get").mockReturnValue(100);
21
-
22
- el.repaint();
23
-
24
- expect(offsetHeightSpy).toHaveBeenCalled();
25
- });
26
- });
27
-
28
- describe("getRelativeOffset", () => {
29
- it("calculates relative position based on parent element", () => {
30
- container.style.position = "relative";
31
- container.innerHTML = `<div id="child" style="position: absolute; top: 50px; left: 30px;"></div>`;
32
-
33
- const child = container.querySelector<HTMLElement>("#child")!;
34
-
35
- const result = child.getRelativeOffset(container);
36
- expect(result).toHaveProperty("top");
37
- expect(result).toHaveProperty("left");
38
- expect(typeof result.top).toBe("number");
39
- expect(typeof result.left).toBe("number");
40
- });
41
-
42
- it("finds parent by selector", () => {
43
- container.id = "parent";
44
- container.innerHTML = `<div><span id="deep-child"></span></div>`;
45
-
46
- const deepChild = container.querySelector<HTMLElement>("#deep-child")!;
47
-
48
- const result = deepChild.getRelativeOffset("#parent");
49
- expect(result).toHaveProperty("top");
50
- expect(result).toHaveProperty("left");
51
- expect(typeof result.top).toBe("number");
52
- expect(typeof result.left).toBe("number");
53
- });
54
-
55
- it("throws error when parent is not found", () => {
56
- const child = document.createElement("div");
57
- container.appendChild(child);
58
-
59
- expect(() => child.getRelativeOffset(".not-exist")).toThrow(ArgumentError);
60
- });
61
-
62
- it("accumulates border width of intermediate elements", () => {
63
- container.style.position = "relative";
64
- container.innerHTML = `
65
- <div id="middle" style="border: 10px solid black;">
66
- <div id="child"></div>
67
- </div>
68
- `;
69
-
70
- const child = container.querySelector<HTMLElement>("#child")!;
71
- const result = child.getRelativeOffset(container);
72
-
73
- // borderTopWidth(10px) and borderLeftWidth(10px) should be reflected in result
74
- expect(result.top).toBeGreaterThanOrEqual(10);
75
- expect(result.left).toBeGreaterThanOrEqual(10);
76
- });
77
-
78
- it("calculates correct position when parent element is scrolled", () => {
79
- container.style.position = "relative";
80
- container.style.overflow = "auto";
81
- container.style.height = "100px";
82
- container.style.width = "100px";
83
-
84
- const inner = document.createElement("div");
85
- inner.style.height = "500px";
86
- inner.style.width = "500px";
87
- inner.innerHTML = `<div id="child" style="position: absolute; top: 200px; left: 150px;"></div>`;
88
- container.appendChild(inner);
89
-
90
- // Scroll parent element
91
- container.scrollTop = 50;
92
- container.scrollLeft = 30;
93
-
94
- const child = container.querySelector<HTMLElement>("#child")!;
95
- const result = child.getRelativeOffset(container);
96
-
97
- // scrollTop/scrollLeft are reflected in result (parentEl.scrollTop + parentEl.scrollLeft added)
98
- // In test environment, getBoundingClientRect does not reflect scroll
99
- // Verify that at least scrollTop/scrollLeft values are added
100
- expect(result.top).toBeGreaterThanOrEqual(200);
101
- expect(result.left).toBeGreaterThanOrEqual(150);
102
- });
103
- });
104
-
105
- describe("scrollIntoViewIfNeeded", () => {
106
- it("scrolls when target is above offset", () => {
107
- container.style.overflow = "auto";
108
- container.style.height = "100px";
109
- // Add scrollable content
110
- const inner = document.createElement("div");
111
- inner.style.height = "500px";
112
- container.appendChild(inner);
113
- container.scrollTop = 100;
114
-
115
- container.scrollIntoViewIfNeeded({ top: 50, left: 0 }, { top: 10, left: 0 });
116
-
117
- expect(container.scrollTop).toBe(40);
118
- });
119
-
120
- it("does not scroll if target is sufficiently visible", () => {
121
- container.style.overflow = "auto";
122
- container.style.height = "100px";
123
- const inner = document.createElement("div");
124
- inner.style.height = "500px";
125
- container.appendChild(inner);
126
- container.scrollTop = 0;
127
-
128
- container.scrollIntoViewIfNeeded({ top: 50, left: 0 }, { top: 10, left: 0 });
129
-
130
- expect(container.scrollTop).toBe(0);
131
- });
132
-
133
- it("defaults to 0 offset", () => {
134
- container.style.overflow = "auto";
135
- container.style.height = "100px";
136
- const inner = document.createElement("div");
137
- inner.style.height = "500px";
138
- container.appendChild(inner);
139
- container.scrollTop = 100;
140
-
141
- container.scrollIntoViewIfNeeded({ top: 50, left: 0 });
142
-
143
- expect(container.scrollTop).toBe(50);
144
- });
145
-
146
- it("scrolls horizontally when target is to the left of offset", () => {
147
- container.style.overflow = "auto";
148
- container.style.width = "100px";
149
- // Add scrollable content
150
- const inner = document.createElement("div");
151
- inner.style.width = "500px";
152
- inner.style.height = "10px";
153
- container.appendChild(inner);
154
- container.scrollLeft = 100;
155
-
156
- container.scrollIntoViewIfNeeded({ top: 0, left: 50 }, { top: 0, left: 10 });
157
-
158
- expect(container.scrollLeft).toBe(40);
159
- });
160
-
161
- it("does not scroll horizontally if target is sufficiently visible", () => {
162
- container.style.overflow = "auto";
163
- container.style.width = "100px";
164
- const inner = document.createElement("div");
165
- inner.style.width = "500px";
166
- inner.style.height = "10px";
167
- container.appendChild(inner);
168
- container.scrollLeft = 0;
169
-
170
- container.scrollIntoViewIfNeeded({ top: 0, left: 50 }, { top: 0, left: 10 });
171
-
172
- expect(container.scrollLeft).toBe(0);
173
- });
174
- });
175
- });
@@ -1,103 +0,0 @@
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
- });
@@ -1,171 +0,0 @@
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
- });
@@ -1,66 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { downloadBlob } from "../../src/utils/download";
3
-
4
- describe("downloadBlob", () => {
5
- let originalCreateObjectURL: typeof URL.createObjectURL;
6
- let originalRevokeObjectURL: typeof URL.revokeObjectURL;
7
- let mockLink: HTMLAnchorElement;
8
- let clickSpy: ReturnType<typeof vi.fn>;
9
-
10
- beforeEach(() => {
11
- vi.useFakeTimers();
12
-
13
- originalCreateObjectURL = URL.createObjectURL;
14
- originalRevokeObjectURL = URL.revokeObjectURL;
15
- URL.createObjectURL = vi.fn().mockReturnValue("blob:mock-url");
16
- URL.revokeObjectURL = vi.fn();
17
-
18
- clickSpy = vi.fn();
19
- mockLink = {
20
- href: "",
21
- download: "",
22
- click: clickSpy,
23
- } as unknown as HTMLAnchorElement;
24
-
25
- vi.spyOn(document, "createElement").mockReturnValue(mockLink);
26
- });
27
-
28
- afterEach(() => {
29
- vi.useRealTimers();
30
- URL.createObjectURL = originalCreateObjectURL;
31
- URL.revokeObjectURL = originalRevokeObjectURL;
32
- vi.restoreAllMocks();
33
- });
34
-
35
- it("converts Blob to download link and clicks it", () => {
36
- const blob = new Blob(["test content"], { type: "text/plain" });
37
- const fileName = "test.txt";
38
-
39
- downloadBlob(blob, fileName);
40
-
41
- expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
42
- expect(mockLink.href).toBe("blob:mock-url");
43
- expect(mockLink.download).toBe(fileName);
44
- expect(clickSpy).toHaveBeenCalled();
45
- });
46
-
47
- it("calls URL.revokeObjectURL after download to prevent memory leak", () => {
48
- const blob = new Blob(["test"], { type: "text/plain" });
49
-
50
- downloadBlob(blob, "test.txt");
51
- vi.runAllTimers();
52
-
53
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
54
- });
55
-
56
- it("calls revokeObjectURL even when error occurs", () => {
57
- const blob = new Blob(["test"], { type: "text/plain" });
58
- clickSpy.mockImplementation(() => {
59
- throw new Error("Click failed");
60
- });
61
-
62
- expect(() => downloadBlob(blob, "test.txt")).toThrow("Click failed");
63
- vi.runAllTimers();
64
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
65
- });
66
- });
@@ -1,154 +0,0 @@
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
- });