@mrdemonwolf/iconwolf 0.1.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.
- package/LICENSE +21 -0
- package/dist/generator.d.ts +3 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +122 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +10 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +9 -0
- package/dist/lib.js.map +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/icon-composer.d.ts +14 -0
- package/dist/utils/icon-composer.d.ts.map +1 -0
- package/dist/utils/icon-composer.js +200 -0
- package/dist/utils/icon-composer.js.map +1 -0
- package/dist/utils/image.d.ts +22 -0
- package/dist/utils/image.d.ts.map +1 -0
- package/dist/utils/image.js +145 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +38 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/paths.d.ts +17 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +27 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/update-notifier.d.ts +9 -0
- package/dist/utils/update-notifier.d.ts.map +1 -0
- package/dist/utils/update-notifier.js +80 -0
- package/dist/utils/update-notifier.js.map +1 -0
- package/dist/variants/android.d.ts +5 -0
- package/dist/variants/android.d.ts.map +1 -0
- package/dist/variants/android.js +18 -0
- package/dist/variants/android.js.map +1 -0
- package/dist/variants/favicon.d.ts +3 -0
- package/dist/variants/favicon.d.ts.map +1 -0
- package/dist/variants/favicon.js +23 -0
- package/dist/variants/favicon.js.map +1 -0
- package/dist/variants/splash.d.ts +3 -0
- package/dist/variants/splash.d.ts.map +1 -0
- package/dist/variants/splash.js +7 -0
- package/dist/variants/splash.js.map +1 -0
- package/dist/variants/standard.d.ts +3 -0
- package/dist/variants/standard.d.ts.map +1 -0
- package/dist/variants/standard.js +7 -0
- package/dist/variants/standard.js.map +1 -0
- package/eslint.config.js +10 -0
- package/package.json +57 -0
- package/scripts/build-release.sh +63 -0
- package/src/generator.ts +163 -0
- package/src/index.ts +73 -0
- package/src/lib.ts +16 -0
- package/src/types.ts +22 -0
- package/src/utils/icon-composer.ts +283 -0
- package/src/utils/image.ts +207 -0
- package/src/utils/logger.ts +61 -0
- package/src/utils/paths.ts +30 -0
- package/src/utils/update-notifier.ts +99 -0
- package/src/variants/android.ts +45 -0
- package/src/variants/favicon.ts +32 -0
- package/src/variants/splash.ts +11 -0
- package/src/variants/standard.ts +11 -0
- package/tests/cli.test.ts +84 -0
- package/tests/generator.test.ts +368 -0
- package/tests/helpers.ts +36 -0
- package/tests/utils/icon-composer.test.ts +208 -0
- package/tests/utils/image.test.ts +207 -0
- package/tests/utils/logger.test.ts +128 -0
- package/tests/utils/paths.test.ts +51 -0
- package/tests/utils/update-notifier.test.ts +184 -0
- package/tests/variants/android.test.ts +77 -0
- package/tests/variants/favicon.test.ts +36 -0
- package/tests/variants/splash.test.ts +35 -0
- package/tests/variants/standard.test.ts +35 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
5
|
+
import { generate } from '../src/generator.js';
|
|
6
|
+
import { OUTPUT_FILES } from '../src/utils/paths.js';
|
|
7
|
+
import { createTestPng, createTmpDir, cleanDir } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let testPng: string;
|
|
11
|
+
let testIconFolder: string;
|
|
12
|
+
|
|
13
|
+
async function createMockIconFolder(dir: string): Promise<string> {
|
|
14
|
+
const iconDir = path.join(dir, 'Test.icon');
|
|
15
|
+
const assetsDir = path.join(iconDir, 'Assets');
|
|
16
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
17
|
+
|
|
18
|
+
await sharp({
|
|
19
|
+
create: {
|
|
20
|
+
width: 200,
|
|
21
|
+
height: 200,
|
|
22
|
+
channels: 4,
|
|
23
|
+
background: { r: 255, g: 107, b: 53, alpha: 255 },
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
.png()
|
|
27
|
+
.toFile(path.join(assetsDir, 'logo.png'));
|
|
28
|
+
|
|
29
|
+
fs.writeFileSync(
|
|
30
|
+
path.join(iconDir, 'icon.json'),
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
fill: {
|
|
33
|
+
'linear-gradient': [
|
|
34
|
+
'display-p3:0.00000,0.67451,0.92941,1.00000',
|
|
35
|
+
'display-p3:0.03529,0.08235,0.20000,1.00000',
|
|
36
|
+
],
|
|
37
|
+
orientation: { start: { x: 0.5, y: 0 }, stop: { x: 0.5, y: 0.7 } },
|
|
38
|
+
},
|
|
39
|
+
groups: [
|
|
40
|
+
{
|
|
41
|
+
layers: [
|
|
42
|
+
{
|
|
43
|
+
'image-name': 'logo.png',
|
|
44
|
+
name: 'logo',
|
|
45
|
+
position: { scale: 1.0, 'translation-in-points': [0, 0] },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return iconDir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
tmpDir = createTmpDir();
|
|
58
|
+
testPng = await createTestPng(1024, 1024, tmpDir);
|
|
59
|
+
testIconFolder = await createMockIconFolder(tmpDir);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
cleanDir(tmpDir);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('generate', () => {
|
|
67
|
+
it('generates 4 default files when no flags set (matches Expo template)', async () => {
|
|
68
|
+
const outDir = path.join(tmpDir, 'all');
|
|
69
|
+
|
|
70
|
+
await generate({
|
|
71
|
+
inputPath: testPng,
|
|
72
|
+
outputDir: outDir,
|
|
73
|
+
variants: { android: false, favicon: false, splash: false, icon: false },
|
|
74
|
+
bgColor: '#FFFFFF',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(fs.existsSync(path.join(outDir, OUTPUT_FILES.icon))).toBe(true);
|
|
78
|
+
expect(
|
|
79
|
+
fs.existsSync(path.join(outDir, OUTPUT_FILES.androidForeground)),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
expect(fs.existsSync(path.join(outDir, OUTPUT_FILES.splashIcon))).toBe(
|
|
82
|
+
true,
|
|
83
|
+
);
|
|
84
|
+
expect(fs.existsSync(path.join(outDir, OUTPUT_FILES.favicon))).toBe(true);
|
|
85
|
+
// Background and monochrome NOT generated by default
|
|
86
|
+
expect(
|
|
87
|
+
fs.existsSync(path.join(outDir, OUTPUT_FILES.androidBackground)),
|
|
88
|
+
).toBe(false);
|
|
89
|
+
expect(
|
|
90
|
+
fs.existsSync(path.join(outDir, OUTPUT_FILES.androidMonochrome)),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('generates only icon when --icon flag set', async () => {
|
|
95
|
+
const outDir = path.join(tmpDir, 'icon-only');
|
|
96
|
+
|
|
97
|
+
await generate({
|
|
98
|
+
inputPath: testPng,
|
|
99
|
+
outputDir: outDir,
|
|
100
|
+
variants: { android: false, favicon: false, splash: false, icon: true },
|
|
101
|
+
bgColor: '#FFFFFF',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(true);
|
|
105
|
+
expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(false);
|
|
106
|
+
expect(fs.existsSync(path.join(outDir, 'splash-icon.png'))).toBe(false);
|
|
107
|
+
expect(fs.existsSync(path.join(outDir, 'adaptive-icon.png'))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('generates all android files when --android flag set', async () => {
|
|
111
|
+
const outDir = path.join(tmpDir, 'android-only');
|
|
112
|
+
|
|
113
|
+
await generate({
|
|
114
|
+
inputPath: testPng,
|
|
115
|
+
outputDir: outDir,
|
|
116
|
+
variants: { android: true, favicon: false, splash: false, icon: false },
|
|
117
|
+
bgColor: '#FFFFFF',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(fs.existsSync(path.join(outDir, 'adaptive-icon.png'))).toBe(true);
|
|
121
|
+
expect(
|
|
122
|
+
fs.existsSync(path.join(outDir, 'android-icon-background.png')),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
expect(fs.existsSync(path.join(outDir, 'monochrome-icon.png'))).toBe(true);
|
|
125
|
+
expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(false);
|
|
126
|
+
expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('throws on missing source file', async () => {
|
|
130
|
+
const outDir = path.join(tmpDir, 'missing');
|
|
131
|
+
|
|
132
|
+
await expect(
|
|
133
|
+
generate({
|
|
134
|
+
inputPath: '/tmp/does-not-exist.png',
|
|
135
|
+
outputDir: outDir,
|
|
136
|
+
variants: {
|
|
137
|
+
android: false,
|
|
138
|
+
favicon: false,
|
|
139
|
+
splash: false,
|
|
140
|
+
icon: false,
|
|
141
|
+
},
|
|
142
|
+
bgColor: '#FFFFFF',
|
|
143
|
+
}),
|
|
144
|
+
).rejects.toThrow('Source not found');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('generates 4 default files from .icon folder', async () => {
|
|
148
|
+
const outDir = path.join(tmpDir, 'icon-composer');
|
|
149
|
+
|
|
150
|
+
await generate({
|
|
151
|
+
inputPath: testIconFolder,
|
|
152
|
+
outputDir: outDir,
|
|
153
|
+
variants: { android: false, favicon: false, splash: false, icon: false },
|
|
154
|
+
bgColor: '#FFFFFF',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(fs.existsSync(path.join(outDir, OUTPUT_FILES.icon))).toBe(true);
|
|
158
|
+
expect(
|
|
159
|
+
fs.existsSync(path.join(outDir, OUTPUT_FILES.androidForeground)),
|
|
160
|
+
).toBe(true);
|
|
161
|
+
expect(fs.existsSync(path.join(outDir, OUTPUT_FILES.splashIcon))).toBe(
|
|
162
|
+
true,
|
|
163
|
+
);
|
|
164
|
+
expect(fs.existsSync(path.join(outDir, OUTPUT_FILES.favicon))).toBe(true);
|
|
165
|
+
expect(
|
|
166
|
+
fs.existsSync(path.join(outDir, OUTPUT_FILES.androidBackground)),
|
|
167
|
+
).toBe(false);
|
|
168
|
+
expect(
|
|
169
|
+
fs.existsSync(path.join(outDir, OUTPUT_FILES.androidMonochrome)),
|
|
170
|
+
).toBe(false);
|
|
171
|
+
|
|
172
|
+
// Verify the composed icon is 1024x1024
|
|
173
|
+
const meta = await sharp(path.join(outDir, 'icon.png')).metadata();
|
|
174
|
+
expect(meta.width).toBe(1024);
|
|
175
|
+
expect(meta.height).toBe(1024);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('creates output directory if it does not exist', async () => {
|
|
179
|
+
const outDir = path.join(tmpDir, 'nested', 'deep', 'dir');
|
|
180
|
+
|
|
181
|
+
await generate({
|
|
182
|
+
inputPath: testPng,
|
|
183
|
+
outputDir: outDir,
|
|
184
|
+
variants: { android: false, favicon: false, splash: false, icon: true },
|
|
185
|
+
bgColor: '#FFFFFF',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(fs.existsSync(outDir)).toBe(true);
|
|
189
|
+
expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('generates only favicon when --favicon flag set', async () => {
|
|
193
|
+
const outDir = path.join(tmpDir, 'favicon-only');
|
|
194
|
+
|
|
195
|
+
await generate({
|
|
196
|
+
inputPath: testPng,
|
|
197
|
+
outputDir: outDir,
|
|
198
|
+
variants: { android: false, favicon: true, splash: false, icon: false },
|
|
199
|
+
bgColor: '#FFFFFF',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(true);
|
|
203
|
+
expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(false);
|
|
204
|
+
expect(fs.existsSync(path.join(outDir, 'splash-icon.png'))).toBe(false);
|
|
205
|
+
expect(fs.existsSync(path.join(outDir, 'adaptive-icon.png'))).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('generates only splash when --splash flag set', async () => {
|
|
209
|
+
const outDir = path.join(tmpDir, 'splash-only');
|
|
210
|
+
|
|
211
|
+
await generate({
|
|
212
|
+
inputPath: testPng,
|
|
213
|
+
outputDir: outDir,
|
|
214
|
+
variants: { android: false, favicon: false, splash: true, icon: false },
|
|
215
|
+
bgColor: '#FFFFFF',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(fs.existsSync(path.join(outDir, 'splash-icon.png'))).toBe(true);
|
|
219
|
+
expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(false);
|
|
220
|
+
expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(false);
|
|
221
|
+
expect(fs.existsSync(path.join(outDir, 'adaptive-icon.png'))).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('generates multiple variants when multiple flags set', async () => {
|
|
225
|
+
const outDir = path.join(tmpDir, 'multi-flags');
|
|
226
|
+
|
|
227
|
+
await generate({
|
|
228
|
+
inputPath: testPng,
|
|
229
|
+
outputDir: outDir,
|
|
230
|
+
variants: { android: true, favicon: true, splash: false, icon: false },
|
|
231
|
+
bgColor: '#FFFFFF',
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(fs.existsSync(path.join(outDir, 'adaptive-icon.png'))).toBe(true);
|
|
235
|
+
expect(
|
|
236
|
+
fs.existsSync(path.join(outDir, 'android-icon-background.png')),
|
|
237
|
+
).toBe(true);
|
|
238
|
+
expect(fs.existsSync(path.join(outDir, 'monochrome-icon.png'))).toBe(true);
|
|
239
|
+
expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(true);
|
|
240
|
+
expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(false);
|
|
241
|
+
expect(fs.existsSync(path.join(outDir, 'splash-icon.png'))).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('uses custom bg-color for .icon folder when provided', async () => {
|
|
245
|
+
const outDir = path.join(tmpDir, 'icon-custom-bg');
|
|
246
|
+
|
|
247
|
+
await generate({
|
|
248
|
+
inputPath: testIconFolder,
|
|
249
|
+
outputDir: outDir,
|
|
250
|
+
variants: { android: true, favicon: false, splash: false, icon: false },
|
|
251
|
+
bgColor: '#FF0000',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(
|
|
255
|
+
fs.existsSync(path.join(outDir, 'android-icon-background.png')),
|
|
256
|
+
).toBe(true);
|
|
257
|
+
|
|
258
|
+
const { channels } = await sharp(
|
|
259
|
+
path.join(outDir, 'android-icon-background.png'),
|
|
260
|
+
).stats();
|
|
261
|
+
expect(channels[0].mean).toBeCloseTo(255, -1); // Red
|
|
262
|
+
expect(channels[1].mean).toBeCloseTo(0, -1); // Green
|
|
263
|
+
expect(channels[2].mean).toBeCloseTo(0, -1); // Blue
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('cleans up temp composed image from .icon folder', async () => {
|
|
267
|
+
const os = await import('node:os');
|
|
268
|
+
const outDir = path.join(tmpDir, 'icon-cleanup');
|
|
269
|
+
|
|
270
|
+
// Count compose dirs before
|
|
271
|
+
const composeDirsBefore = fs
|
|
272
|
+
.readdirSync(os.tmpdir())
|
|
273
|
+
.filter((d) => d.startsWith('iconwolf-compose-'));
|
|
274
|
+
|
|
275
|
+
await generate({
|
|
276
|
+
inputPath: testIconFolder,
|
|
277
|
+
outputDir: outDir,
|
|
278
|
+
variants: { android: false, favicon: false, splash: false, icon: true },
|
|
279
|
+
bgColor: '#FFFFFF',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// No new compose dirs should remain after generation
|
|
283
|
+
const composeDirsAfter = fs
|
|
284
|
+
.readdirSync(os.tmpdir())
|
|
285
|
+
.filter((d) => d.startsWith('iconwolf-compose-'));
|
|
286
|
+
expect(composeDirsAfter.length).toBeLessThanOrEqual(
|
|
287
|
+
composeDirsBefore.length,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('throws on directory that is not a .icon folder or PNG', async () => {
|
|
294
|
+
const fakeDir = path.join(tmpDir, 'not-icon-dir');
|
|
295
|
+
fs.mkdirSync(fakeDir, { recursive: true });
|
|
296
|
+
fs.writeFileSync(path.join(fakeDir, 'dummy.txt'), 'test');
|
|
297
|
+
|
|
298
|
+
await expect(
|
|
299
|
+
generate({
|
|
300
|
+
inputPath: fakeDir,
|
|
301
|
+
outputDir: path.join(tmpDir, 'err-out'),
|
|
302
|
+
variants: {
|
|
303
|
+
android: false,
|
|
304
|
+
favicon: false,
|
|
305
|
+
splash: false,
|
|
306
|
+
icon: false,
|
|
307
|
+
},
|
|
308
|
+
bgColor: '#FFFFFF',
|
|
309
|
+
}),
|
|
310
|
+
).rejects.toThrow();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('favicon has rounded corners with transparent pixels', async () => {
|
|
314
|
+
const outDir = path.join(tmpDir, 'favicon-corners');
|
|
315
|
+
|
|
316
|
+
await generate({
|
|
317
|
+
inputPath: testPng,
|
|
318
|
+
outputDir: outDir,
|
|
319
|
+
variants: { android: false, favicon: true, splash: false, icon: false },
|
|
320
|
+
bgColor: '#FFFFFF',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const faviconPath = path.join(outDir, 'favicon.png');
|
|
324
|
+
expect(fs.existsSync(faviconPath)).toBe(true);
|
|
325
|
+
|
|
326
|
+
const { data, info } = await sharp(faviconPath)
|
|
327
|
+
.raw()
|
|
328
|
+
.toBuffer({ resolveWithObject: true });
|
|
329
|
+
|
|
330
|
+
// Top-left corner should be transparent (rounded)
|
|
331
|
+
expect(data[3]).toBe(0); // alpha of pixel (0,0)
|
|
332
|
+
expect(info.channels).toBe(4);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('uses separate splash input when --splash-input provided', async () => {
|
|
336
|
+
const outDir = path.join(tmpDir, 'splash-input');
|
|
337
|
+
const splashPng = await createTestPng(512, 512, tmpDir, 'splash-src.png');
|
|
338
|
+
|
|
339
|
+
await generate({
|
|
340
|
+
inputPath: testPng,
|
|
341
|
+
outputDir: outDir,
|
|
342
|
+
variants: { android: false, favicon: false, splash: true, icon: false },
|
|
343
|
+
bgColor: '#FFFFFF',
|
|
344
|
+
splashInputPath: splashPng,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(fs.existsSync(path.join(outDir, 'splash-icon.png'))).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('throws on missing splash input file', async () => {
|
|
351
|
+
const outDir = path.join(tmpDir, 'splash-missing');
|
|
352
|
+
|
|
353
|
+
await expect(
|
|
354
|
+
generate({
|
|
355
|
+
inputPath: testPng,
|
|
356
|
+
outputDir: outDir,
|
|
357
|
+
variants: {
|
|
358
|
+
android: false,
|
|
359
|
+
favicon: false,
|
|
360
|
+
splash: true,
|
|
361
|
+
icon: false,
|
|
362
|
+
},
|
|
363
|
+
bgColor: '#FFFFFF',
|
|
364
|
+
splashInputPath: '/tmp/nonexistent-splash.png',
|
|
365
|
+
}),
|
|
366
|
+
).rejects.toThrow('Splash source not found');
|
|
367
|
+
});
|
|
368
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
|
|
6
|
+
export async function createTestPng(
|
|
7
|
+
width: number,
|
|
8
|
+
height: number,
|
|
9
|
+
dir?: string,
|
|
10
|
+
filename?: string,
|
|
11
|
+
): Promise<string> {
|
|
12
|
+
const tmpDir =
|
|
13
|
+
dir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'iconwolf-test-'));
|
|
14
|
+
const filePath = path.join(tmpDir, filename ?? 'test-icon.png');
|
|
15
|
+
|
|
16
|
+
await sharp({
|
|
17
|
+
create: {
|
|
18
|
+
width,
|
|
19
|
+
height,
|
|
20
|
+
channels: 4,
|
|
21
|
+
background: { r: 255, g: 107, b: 53, alpha: 255 },
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
.png()
|
|
25
|
+
.toFile(filePath);
|
|
26
|
+
|
|
27
|
+
return filePath;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createTmpDir(): string {
|
|
31
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'iconwolf-test-'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function cleanDir(dir: string): void {
|
|
35
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
isIconComposerFolder,
|
|
7
|
+
renderIconComposerFolder,
|
|
8
|
+
} from '../../src/utils/icon-composer.js';
|
|
9
|
+
import { createTmpDir, cleanDir } from '../helpers.js';
|
|
10
|
+
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
tmpDir = createTmpDir();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterAll(() => {
|
|
18
|
+
cleanDir(tmpDir);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a mock .icon folder with a gradient background and a simple layer.
|
|
23
|
+
*/
|
|
24
|
+
async function createMockIconFolder(
|
|
25
|
+
dir: string,
|
|
26
|
+
opts?: { solidFill?: boolean; noFill?: boolean },
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const iconDir = path.join(dir, 'TestIcon.icon');
|
|
29
|
+
const assetsDir = path.join(iconDir, 'Assets');
|
|
30
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
// Create a 200x200 test layer image
|
|
33
|
+
await sharp({
|
|
34
|
+
create: {
|
|
35
|
+
width: 200,
|
|
36
|
+
height: 200,
|
|
37
|
+
channels: 4,
|
|
38
|
+
background: { r: 255, g: 255, b: 255, alpha: 255 },
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
.png()
|
|
42
|
+
.toFile(path.join(assetsDir, 'layer.png'));
|
|
43
|
+
|
|
44
|
+
let fill: unknown;
|
|
45
|
+
if (opts?.solidFill) {
|
|
46
|
+
fill = { solid: 'srgb:1.00000,0.00000,0.00000,1.00000' };
|
|
47
|
+
} else if (opts?.noFill) {
|
|
48
|
+
fill = {};
|
|
49
|
+
} else {
|
|
50
|
+
fill = {
|
|
51
|
+
'linear-gradient': [
|
|
52
|
+
'display-p3:0.00000,0.67451,0.92941,1.00000',
|
|
53
|
+
'display-p3:0.03529,0.08235,0.20000,1.00000',
|
|
54
|
+
],
|
|
55
|
+
orientation: {
|
|
56
|
+
start: { x: 0.5, y: 0 },
|
|
57
|
+
stop: { x: 0.5, y: 0.7 },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const manifest = {
|
|
63
|
+
fill,
|
|
64
|
+
groups: [
|
|
65
|
+
{
|
|
66
|
+
layers: [
|
|
67
|
+
{
|
|
68
|
+
'image-name': 'layer.png',
|
|
69
|
+
name: 'layer',
|
|
70
|
+
position: {
|
|
71
|
+
scale: 1.0,
|
|
72
|
+
'translation-in-points': [0, 0],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
path.join(iconDir, 'icon.json'),
|
|
82
|
+
JSON.stringify(manifest, null, 2),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return iconDir;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('isIconComposerFolder', () => {
|
|
89
|
+
it('returns true for valid .icon folder', async () => {
|
|
90
|
+
const iconDir = await createMockIconFolder(path.join(tmpDir, 'valid'));
|
|
91
|
+
expect(isIconComposerFolder(iconDir)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns false for non-.icon path', () => {
|
|
95
|
+
expect(isIconComposerFolder('/tmp/test.png')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns false for .icon file that is not a directory', () => {
|
|
99
|
+
const fakePath = path.join(tmpDir, 'fake.icon');
|
|
100
|
+
fs.writeFileSync(fakePath, 'not a directory');
|
|
101
|
+
expect(isIconComposerFolder(fakePath)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns false for .icon directory without icon.json', () => {
|
|
105
|
+
const emptyDir = path.join(tmpDir, 'empty.icon');
|
|
106
|
+
fs.mkdirSync(emptyDir, { recursive: true });
|
|
107
|
+
expect(isIconComposerFolder(emptyDir)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('renderIconComposerFolder', () => {
|
|
112
|
+
it('renders a gradient .icon folder to 1024x1024 PNG', async () => {
|
|
113
|
+
const iconDir = await createMockIconFolder(
|
|
114
|
+
path.join(tmpDir, 'render-gradient'),
|
|
115
|
+
);
|
|
116
|
+
const result = await renderIconComposerFolder(iconDir);
|
|
117
|
+
|
|
118
|
+
expect(fs.existsSync(result.composedImagePath)).toBe(true);
|
|
119
|
+
|
|
120
|
+
const meta = await sharp(result.composedImagePath).metadata();
|
|
121
|
+
expect(meta.width).toBe(1024);
|
|
122
|
+
expect(meta.height).toBe(1024);
|
|
123
|
+
expect(meta.format).toBe('png');
|
|
124
|
+
|
|
125
|
+
// Clean up temp file
|
|
126
|
+
fs.rmSync(path.dirname(result.composedImagePath), {
|
|
127
|
+
recursive: true,
|
|
128
|
+
force: true,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('extracts background color from gradient', async () => {
|
|
133
|
+
const iconDir = await createMockIconFolder(path.join(tmpDir, 'render-bg'));
|
|
134
|
+
const result = await renderIconComposerFolder(iconDir);
|
|
135
|
+
|
|
136
|
+
// First gradient stop: display-p3:0.00000,0.67451,0.92941 → #00ACED
|
|
137
|
+
expect(result.extractedBgColor).toBe('#00ACED');
|
|
138
|
+
|
|
139
|
+
fs.rmSync(path.dirname(result.composedImagePath), {
|
|
140
|
+
recursive: true,
|
|
141
|
+
force: true,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('handles solid fill', async () => {
|
|
146
|
+
const iconDir = await createMockIconFolder(
|
|
147
|
+
path.join(tmpDir, 'render-solid'),
|
|
148
|
+
{
|
|
149
|
+
solidFill: true,
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const result = await renderIconComposerFolder(iconDir);
|
|
153
|
+
|
|
154
|
+
expect(result.extractedBgColor).toBe('#FF0000');
|
|
155
|
+
|
|
156
|
+
const meta = await sharp(result.composedImagePath).metadata();
|
|
157
|
+
expect(meta.width).toBe(1024);
|
|
158
|
+
expect(meta.height).toBe(1024);
|
|
159
|
+
|
|
160
|
+
fs.rmSync(path.dirname(result.composedImagePath), {
|
|
161
|
+
recursive: true,
|
|
162
|
+
force: true,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('falls back to white when no fill specified', async () => {
|
|
167
|
+
const iconDir = await createMockIconFolder(
|
|
168
|
+
path.join(tmpDir, 'render-nofill'),
|
|
169
|
+
{
|
|
170
|
+
noFill: true,
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
const result = await renderIconComposerFolder(iconDir);
|
|
174
|
+
|
|
175
|
+
expect(result.extractedBgColor).toBe('#FFFFFF');
|
|
176
|
+
|
|
177
|
+
fs.rmSync(path.dirname(result.composedImagePath), {
|
|
178
|
+
recursive: true,
|
|
179
|
+
force: true,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('throws on missing layer image', async () => {
|
|
184
|
+
const iconDir = path.join(tmpDir, 'missing-layer.icon');
|
|
185
|
+
fs.mkdirSync(path.join(iconDir, 'Assets'), { recursive: true });
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
path.join(iconDir, 'icon.json'),
|
|
188
|
+
JSON.stringify({
|
|
189
|
+
fill: {},
|
|
190
|
+
groups: [
|
|
191
|
+
{
|
|
192
|
+
layers: [
|
|
193
|
+
{
|
|
194
|
+
'image-name': 'nonexistent.png',
|
|
195
|
+
name: 'missing',
|
|
196
|
+
position: { scale: 1, 'translation-in-points': [0, 0] },
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await expect(renderIconComposerFolder(iconDir)).rejects.toThrow(
|
|
205
|
+
'Layer image not found',
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
});
|