@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,207 @@
|
|
|
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
|
+
parseHexColor,
|
|
7
|
+
validateSourceImage,
|
|
8
|
+
resizeImage,
|
|
9
|
+
createAdaptiveForeground,
|
|
10
|
+
createSolidBackground,
|
|
11
|
+
createMonochromeIcon,
|
|
12
|
+
applyRoundedCorners,
|
|
13
|
+
} from '../../src/utils/image.js';
|
|
14
|
+
import { createTestPng, createTmpDir, cleanDir } from '../helpers.js';
|
|
15
|
+
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
let testPng: string;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
tmpDir = createTmpDir();
|
|
21
|
+
testPng = await createTestPng(1024, 1024, tmpDir);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
cleanDir(tmpDir);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('parseHexColor', () => {
|
|
29
|
+
it('parses 6-digit hex with #', () => {
|
|
30
|
+
expect(parseHexColor('#FF6B35')).toEqual({ r: 255, g: 107, b: 53 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('parses 6-digit hex without #', () => {
|
|
34
|
+
expect(parseHexColor('FF6B35')).toEqual({ r: 255, g: 107, b: 53 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('parses 3-digit shorthand', () => {
|
|
38
|
+
expect(parseHexColor('#FFF')).toEqual({ r: 255, g: 255, b: 255 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('parses black', () => {
|
|
42
|
+
expect(parseHexColor('#000000')).toEqual({ r: 0, g: 0, b: 0 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws on invalid length', () => {
|
|
46
|
+
expect(() => parseHexColor('#FFFFF')).toThrow('Invalid hex color');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('throws on non-hex characters', () => {
|
|
50
|
+
expect(() => parseHexColor('#GGGGGG')).toThrow('non-hex characters');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('validateSourceImage', () => {
|
|
55
|
+
it('accepts a valid square PNG', async () => {
|
|
56
|
+
const meta = await validateSourceImage(testPng);
|
|
57
|
+
expect(meta.width).toBe(1024);
|
|
58
|
+
expect(meta.height).toBe(1024);
|
|
59
|
+
expect(meta.format).toBe('png');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects non-square images', async () => {
|
|
63
|
+
const rectPng = path.join(tmpDir, 'rect.png');
|
|
64
|
+
await sharp({
|
|
65
|
+
create: {
|
|
66
|
+
width: 1024,
|
|
67
|
+
height: 512,
|
|
68
|
+
channels: 4,
|
|
69
|
+
background: { r: 0, g: 0, b: 0, alpha: 255 },
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
.png()
|
|
73
|
+
.toFile(rectPng);
|
|
74
|
+
|
|
75
|
+
await expect(validateSourceImage(rectPng)).rejects.toThrow(
|
|
76
|
+
'must be square',
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects non-PNG formats', async () => {
|
|
81
|
+
const jpgFile = path.join(tmpDir, 'test.jpg');
|
|
82
|
+
await sharp({
|
|
83
|
+
create: {
|
|
84
|
+
width: 100,
|
|
85
|
+
height: 100,
|
|
86
|
+
channels: 3,
|
|
87
|
+
background: { r: 0, g: 0, b: 0 },
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
.jpeg()
|
|
91
|
+
.toFile(jpgFile);
|
|
92
|
+
|
|
93
|
+
await expect(validateSourceImage(jpgFile)).rejects.toThrow('must be a PNG');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws on missing file', async () => {
|
|
97
|
+
await expect(validateSourceImage('/tmp/nonexistent.png')).rejects.toThrow();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('resizeImage', () => {
|
|
102
|
+
it('resizes to target dimensions', async () => {
|
|
103
|
+
const output = path.join(tmpDir, 'resized.png');
|
|
104
|
+
const result = await resizeImage(testPng, 48, 48, output);
|
|
105
|
+
|
|
106
|
+
expect(result.width).toBe(48);
|
|
107
|
+
expect(result.height).toBe(48);
|
|
108
|
+
expect(result.size).toBeGreaterThan(0);
|
|
109
|
+
expect(fs.existsSync(output)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('produces a valid PNG', async () => {
|
|
113
|
+
const output = path.join(tmpDir, 'resized-check.png');
|
|
114
|
+
await resizeImage(testPng, 256, 256, output);
|
|
115
|
+
|
|
116
|
+
const meta = await sharp(output).metadata();
|
|
117
|
+
expect(meta.format).toBe('png');
|
|
118
|
+
expect(meta.width).toBe(256);
|
|
119
|
+
expect(meta.height).toBe(256);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('createAdaptiveForeground', () => {
|
|
124
|
+
it('creates a 1024x1024 foreground with centered artwork', async () => {
|
|
125
|
+
const output = path.join(tmpDir, 'foreground.png');
|
|
126
|
+
const result = await createAdaptiveForeground(testPng, 1024, output);
|
|
127
|
+
|
|
128
|
+
expect(result.width).toBe(1024);
|
|
129
|
+
expect(result.height).toBe(1024);
|
|
130
|
+
expect(fs.existsSync(output)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('createSolidBackground', () => {
|
|
135
|
+
it('creates a solid color PNG', async () => {
|
|
136
|
+
const output = path.join(tmpDir, 'bg.png');
|
|
137
|
+
const result = await createSolidBackground('#FF0000', 1024, output);
|
|
138
|
+
|
|
139
|
+
expect(result.width).toBe(1024);
|
|
140
|
+
expect(result.height).toBe(1024);
|
|
141
|
+
|
|
142
|
+
const meta = await sharp(output).metadata();
|
|
143
|
+
expect(meta.format).toBe('png');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('createMonochromeIcon', () => {
|
|
148
|
+
it('creates a grayscale 1024x1024 icon', async () => {
|
|
149
|
+
const output = path.join(tmpDir, 'mono.png');
|
|
150
|
+
const result = await createMonochromeIcon(testPng, 1024, output);
|
|
151
|
+
|
|
152
|
+
expect(result.width).toBe(1024);
|
|
153
|
+
expect(result.height).toBe(1024);
|
|
154
|
+
expect(fs.existsSync(output)).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('applyRoundedCorners', () => {
|
|
159
|
+
it('returns a PNG buffer with alpha channel', async () => {
|
|
160
|
+
const input = await sharp({
|
|
161
|
+
create: {
|
|
162
|
+
width: 48,
|
|
163
|
+
height: 48,
|
|
164
|
+
channels: 4,
|
|
165
|
+
background: { r: 255, g: 0, b: 0, alpha: 255 },
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
.png()
|
|
169
|
+
.toBuffer();
|
|
170
|
+
|
|
171
|
+
const result = await applyRoundedCorners(input, 48);
|
|
172
|
+
const meta = await sharp(result).metadata();
|
|
173
|
+
|
|
174
|
+
expect(meta.format).toBe('png');
|
|
175
|
+
expect(meta.width).toBe(48);
|
|
176
|
+
expect(meta.height).toBe(48);
|
|
177
|
+
expect(meta.channels).toBe(4);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('produces transparent corners', async () => {
|
|
181
|
+
const input = await sharp({
|
|
182
|
+
create: {
|
|
183
|
+
width: 100,
|
|
184
|
+
height: 100,
|
|
185
|
+
channels: 4,
|
|
186
|
+
background: { r: 255, g: 0, b: 0, alpha: 255 },
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
.png()
|
|
190
|
+
.toBuffer();
|
|
191
|
+
|
|
192
|
+
const result = await applyRoundedCorners(input, 100);
|
|
193
|
+
const { data, info } = await sharp(result)
|
|
194
|
+
.raw()
|
|
195
|
+
.toBuffer({ resolveWithObject: true });
|
|
196
|
+
|
|
197
|
+
// Top-left corner pixel (0,0) should be transparent due to rounding
|
|
198
|
+
const topLeftAlpha = data[3]; // RGBA, alpha is at index 3
|
|
199
|
+
expect(topLeftAlpha).toBe(0);
|
|
200
|
+
|
|
201
|
+
// Center pixel should be fully opaque
|
|
202
|
+
const centerIdx =
|
|
203
|
+
(Math.floor(info.height / 2) * info.width + Math.floor(info.width / 2)) *
|
|
204
|
+
4;
|
|
205
|
+
expect(data[centerIdx + 3]).toBe(255);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as logger from '../../src/utils/logger.js';
|
|
3
|
+
|
|
4
|
+
let logOutput: string[];
|
|
5
|
+
let errorOutput: string[];
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
logOutput = [];
|
|
9
|
+
errorOutput = [];
|
|
10
|
+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
|
|
11
|
+
logOutput.push(args.map(String).join(' '));
|
|
12
|
+
});
|
|
13
|
+
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
|
14
|
+
errorOutput.push(args.map(String).join(' '));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('banner', () => {
|
|
19
|
+
it('prints the iconwolf banner', () => {
|
|
20
|
+
logger.banner();
|
|
21
|
+
expect(logOutput).toHaveLength(1);
|
|
22
|
+
expect(logOutput[0]).toContain('iconwolf');
|
|
23
|
+
expect(logOutput[0]).toContain('app icon generator');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('info', () => {
|
|
28
|
+
it('prints an info message', () => {
|
|
29
|
+
logger.info('test message');
|
|
30
|
+
expect(logOutput).toHaveLength(1);
|
|
31
|
+
expect(logOutput[0]).toContain('info');
|
|
32
|
+
expect(logOutput[0]).toContain('test message');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('success', () => {
|
|
37
|
+
it('prints a success message', () => {
|
|
38
|
+
logger.success('operation complete');
|
|
39
|
+
expect(logOutput).toHaveLength(1);
|
|
40
|
+
expect(logOutput[0]).toContain('done');
|
|
41
|
+
expect(logOutput[0]).toContain('operation complete');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('generated', () => {
|
|
46
|
+
it('prints file path and dimensions', () => {
|
|
47
|
+
logger.generated({
|
|
48
|
+
filePath: '/output/icon.png',
|
|
49
|
+
width: 1024,
|
|
50
|
+
height: 1024,
|
|
51
|
+
size: 6800,
|
|
52
|
+
});
|
|
53
|
+
expect(logOutput).toHaveLength(1);
|
|
54
|
+
expect(logOutput[0]).toContain('generated');
|
|
55
|
+
expect(logOutput[0]).toContain('/output/icon.png');
|
|
56
|
+
expect(logOutput[0]).toContain('1024x1024');
|
|
57
|
+
expect(logOutput[0]).toContain('6.6 KB');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('formats sub-KB sizes', () => {
|
|
61
|
+
logger.generated({
|
|
62
|
+
filePath: '/output/favicon.png',
|
|
63
|
+
width: 48,
|
|
64
|
+
height: 48,
|
|
65
|
+
size: 400,
|
|
66
|
+
});
|
|
67
|
+
expect(logOutput[0]).toContain('0.4 KB');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('warn', () => {
|
|
72
|
+
it('prints a warning message', () => {
|
|
73
|
+
logger.warn('something off');
|
|
74
|
+
expect(logOutput).toHaveLength(1);
|
|
75
|
+
expect(logOutput[0]).toContain('warn');
|
|
76
|
+
expect(logOutput[0]).toContain('something off');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('error', () => {
|
|
81
|
+
it('prints to stderr', () => {
|
|
82
|
+
logger.error('something broke');
|
|
83
|
+
expect(errorOutput).toHaveLength(1);
|
|
84
|
+
expect(errorOutput[0]).toContain('error');
|
|
85
|
+
expect(errorOutput[0]).toContain('something broke');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not print to stdout', () => {
|
|
89
|
+
logger.error('fail');
|
|
90
|
+
expect(logOutput).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('summary', () => {
|
|
95
|
+
it('prints file count and total size', () => {
|
|
96
|
+
logger.summary([
|
|
97
|
+
{ filePath: '/a.png', width: 1024, height: 1024, size: 5000 },
|
|
98
|
+
{ filePath: '/b.png', width: 1024, height: 1024, size: 3000 },
|
|
99
|
+
]);
|
|
100
|
+
expect(logOutput).toHaveLength(1);
|
|
101
|
+
expect(logOutput[0]).toContain('2 files generated');
|
|
102
|
+
expect(logOutput[0]).toContain('7.8 KB total');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('uses singular for one file', () => {
|
|
106
|
+
logger.summary([
|
|
107
|
+
{ filePath: '/a.png', width: 1024, height: 1024, size: 1024 },
|
|
108
|
+
]);
|
|
109
|
+
expect(logOutput[0]).toContain('1 file generated');
|
|
110
|
+
expect(logOutput[0]).not.toContain('files');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('updateNotice', () => {
|
|
115
|
+
it('prints version upgrade info', () => {
|
|
116
|
+
logger.updateNotice('0.0.6', '0.0.7');
|
|
117
|
+
expect(logOutput).toHaveLength(2);
|
|
118
|
+
expect(logOutput[0]).toContain('update');
|
|
119
|
+
expect(logOutput[0]).toContain('0.0.6');
|
|
120
|
+
expect(logOutput[0]).toContain('0.0.7');
|
|
121
|
+
expect(logOutput[0]).toContain('New version available');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('includes brew upgrade instruction', () => {
|
|
125
|
+
logger.updateNotice('0.0.6', '0.0.7');
|
|
126
|
+
expect(logOutput[1]).toContain('brew upgrade iconwolf');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
resolveOutputPath,
|
|
6
|
+
OUTPUT_FILES,
|
|
7
|
+
DEFAULT_OUTPUT_DIR,
|
|
8
|
+
resolveDefaultOutputDir,
|
|
9
|
+
} from '../../src/utils/paths.js';
|
|
10
|
+
|
|
11
|
+
describe('paths', () => {
|
|
12
|
+
it('DEFAULT_OUTPUT_DIR is ./assets/images', () => {
|
|
13
|
+
expect(DEFAULT_OUTPUT_DIR).toBe('./assets/images');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('resolveDefaultOutputDir returns src/assets/images when src/ exists', () => {
|
|
17
|
+
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
|
18
|
+
vi.spyOn(fs, 'statSync').mockReturnValue({
|
|
19
|
+
isDirectory: () => true,
|
|
20
|
+
} as fs.Stats);
|
|
21
|
+
expect(resolveDefaultOutputDir()).toBe('./src/assets/images');
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('resolveDefaultOutputDir returns assets/images when no src/', () => {
|
|
26
|
+
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
27
|
+
expect(resolveDefaultOutputDir()).toBe('./assets/images');
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('OUTPUT_FILES contains all expected file names', () => {
|
|
32
|
+
expect(OUTPUT_FILES.icon).toBe('icon.png');
|
|
33
|
+
expect(OUTPUT_FILES.androidForeground).toBe('adaptive-icon.png');
|
|
34
|
+
expect(OUTPUT_FILES.androidBackground).toBe('android-icon-background.png');
|
|
35
|
+
expect(OUTPUT_FILES.androidMonochrome).toBe('monochrome-icon.png');
|
|
36
|
+
expect(OUTPUT_FILES.favicon).toBe('favicon.png');
|
|
37
|
+
expect(OUTPUT_FILES.splashIcon).toBe('splash-icon.png');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('resolveOutputPath returns absolute path', () => {
|
|
41
|
+
const result = resolveOutputPath('/tmp/out', 'icon.png');
|
|
42
|
+
expect(result).toBe(path.resolve('/tmp/out', 'icon.png'));
|
|
43
|
+
expect(path.isAbsolute(result)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('resolveOutputPath resolves relative dirs', () => {
|
|
47
|
+
const result = resolveOutputPath('./assets', 'favicon.png');
|
|
48
|
+
expect(path.isAbsolute(result)).toBe(true);
|
|
49
|
+
expect(result.endsWith('favicon.png')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
isNewerVersion,
|
|
7
|
+
readCachedUpdateInfo,
|
|
8
|
+
refreshCacheInBackground,
|
|
9
|
+
} from '../../src/utils/update-notifier.js';
|
|
10
|
+
|
|
11
|
+
describe('isNewerVersion', () => {
|
|
12
|
+
it('returns true when latest patch is newer', () => {
|
|
13
|
+
expect(isNewerVersion('0.0.6', '0.0.7')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns true when latest minor is newer', () => {
|
|
17
|
+
expect(isNewerVersion('0.0.6', '0.1.0')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns true when latest major is newer', () => {
|
|
21
|
+
expect(isNewerVersion('0.0.6', '1.0.0')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns false when versions are equal', () => {
|
|
25
|
+
expect(isNewerVersion('0.0.6', '0.0.6')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns false when current is newer', () => {
|
|
29
|
+
expect(isNewerVersion('0.0.7', '0.0.6')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles different segment lengths', () => {
|
|
33
|
+
expect(isNewerVersion('1.0', '1.0.1')).toBe(true);
|
|
34
|
+
expect(isNewerVersion('1.0.1', '1.0')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('readCachedUpdateInfo', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null when cache file is missing', () => {
|
|
44
|
+
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
|
|
45
|
+
throw new Error('ENOENT');
|
|
46
|
+
});
|
|
47
|
+
expect(readCachedUpdateInfo('0.0.6')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns updateAvailable true when latest is newer', () => {
|
|
51
|
+
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
|
52
|
+
JSON.stringify({ latestVersion: '0.0.7', checkedAt: Date.now() }),
|
|
53
|
+
);
|
|
54
|
+
const result = readCachedUpdateInfo('0.0.6');
|
|
55
|
+
expect(result).toEqual({
|
|
56
|
+
updateAvailable: true,
|
|
57
|
+
currentVersion: '0.0.6',
|
|
58
|
+
latestVersion: '0.0.7',
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns updateAvailable false when versions are equal', () => {
|
|
63
|
+
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
|
64
|
+
JSON.stringify({ latestVersion: '0.0.6', checkedAt: Date.now() }),
|
|
65
|
+
);
|
|
66
|
+
const result = readCachedUpdateInfo('0.0.6');
|
|
67
|
+
expect(result).toEqual({
|
|
68
|
+
updateAvailable: false,
|
|
69
|
+
currentVersion: '0.0.6',
|
|
70
|
+
latestVersion: '0.0.6',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns null for corrupt JSON', () => {
|
|
75
|
+
vi.spyOn(fs, 'readFileSync').mockReturnValue('not json{{{');
|
|
76
|
+
expect(readCachedUpdateInfo('0.0.6')).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns null when latestVersion field is missing', () => {
|
|
80
|
+
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
|
81
|
+
JSON.stringify({ checkedAt: Date.now() }),
|
|
82
|
+
);
|
|
83
|
+
expect(readCachedUpdateInfo('0.0.6')).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('refreshCacheInBackground', () => {
|
|
88
|
+
let tmpDir: string;
|
|
89
|
+
let tmpCacheFile: string;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iconwolf-test-'));
|
|
93
|
+
tmpCacheFile = path.join(tmpDir, 'update-check.json');
|
|
94
|
+
vi.restoreAllMocks();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('skips fetch when cache is fresh', async () => {
|
|
102
|
+
fs.writeFileSync(
|
|
103
|
+
tmpCacheFile,
|
|
104
|
+
JSON.stringify({ latestVersion: '0.0.6', checkedAt: Date.now() }),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
108
|
+
await refreshCacheInBackground(tmpCacheFile);
|
|
109
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('fetches when cache is stale', async () => {
|
|
113
|
+
// Write cache with old mtime
|
|
114
|
+
fs.writeFileSync(
|
|
115
|
+
tmpCacheFile,
|
|
116
|
+
JSON.stringify({ latestVersion: '0.0.6', checkedAt: Date.now() }),
|
|
117
|
+
);
|
|
118
|
+
// Make it old by setting mtime to 25 hours ago
|
|
119
|
+
const staleTime = new Date(Date.now() - 25 * 60 * 60 * 1000);
|
|
120
|
+
fs.utimesSync(tmpCacheFile, staleTime, staleTime);
|
|
121
|
+
|
|
122
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
123
|
+
new Response(JSON.stringify({ tag_name: 'v0.0.7' }), { status: 200 }),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
await refreshCacheInBackground(tmpCacheFile);
|
|
127
|
+
|
|
128
|
+
const cache = JSON.parse(fs.readFileSync(tmpCacheFile, 'utf-8'));
|
|
129
|
+
expect(cache.latestVersion).toBe('0.0.7');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('fetches when cache is missing', async () => {
|
|
133
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
134
|
+
new Response(JSON.stringify({ tag_name: 'v0.0.7' }), { status: 200 }),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await refreshCacheInBackground(tmpCacheFile);
|
|
138
|
+
|
|
139
|
+
const cache = JSON.parse(fs.readFileSync(tmpCacheFile, 'utf-8'));
|
|
140
|
+
expect(cache.latestVersion).toBe('0.0.7');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('strips v prefix from tag_name', async () => {
|
|
144
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
145
|
+
new Response(JSON.stringify({ tag_name: 'v1.2.3' }), { status: 200 }),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await refreshCacheInBackground(tmpCacheFile);
|
|
149
|
+
|
|
150
|
+
const cache = JSON.parse(fs.readFileSync(tmpCacheFile, 'utf-8'));
|
|
151
|
+
expect(cache.latestVersion).toBe('1.2.3');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('handles network errors silently', async () => {
|
|
155
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network fail'));
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
refreshCacheInBackground(tmpCacheFile),
|
|
159
|
+
).resolves.toBeUndefined();
|
|
160
|
+
expect(fs.existsSync(tmpCacheFile)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('handles non-ok responses silently', async () => {
|
|
164
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
165
|
+
new Response('Not Found', { status: 404 }),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
refreshCacheInBackground(tmpCacheFile),
|
|
170
|
+
).resolves.toBeUndefined();
|
|
171
|
+
expect(fs.existsSync(tmpCacheFile)).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('handles missing tag_name silently', async () => {
|
|
175
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
176
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await expect(
|
|
180
|
+
refreshCacheInBackground(tmpCacheFile),
|
|
181
|
+
).resolves.toBeUndefined();
|
|
182
|
+
expect(fs.existsSync(tmpCacheFile)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { generateAndroidIcons } from '../../src/variants/android.js';
|
|
6
|
+
import { createTestPng, createTmpDir, cleanDir } from '../helpers.js';
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let testPng: string;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
tmpDir = createTmpDir();
|
|
13
|
+
testPng = await createTestPng(1024, 1024, tmpDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
cleanDir(tmpDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('generateAndroidIcons', () => {
|
|
21
|
+
it('generates only foreground by default', async () => {
|
|
22
|
+
const outDir = path.join(tmpDir, 'fg-only');
|
|
23
|
+
fs.mkdirSync(outDir);
|
|
24
|
+
const results = await generateAndroidIcons(testPng, outDir, '#FFFFFF');
|
|
25
|
+
|
|
26
|
+
expect(results).toHaveLength(1);
|
|
27
|
+
expect(results[0].width).toBe(1024);
|
|
28
|
+
expect(results[0].height).toBe(1024);
|
|
29
|
+
expect(fs.existsSync(results[0].filePath)).toBe(true);
|
|
30
|
+
expect(path.basename(results[0].filePath)).toBe('adaptive-icon.png');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('generates 3 android icon files with includeBackground', async () => {
|
|
34
|
+
const outDir = path.join(tmpDir, 'all-android');
|
|
35
|
+
fs.mkdirSync(outDir);
|
|
36
|
+
const results = await generateAndroidIcons(testPng, outDir, '#FFFFFF', {
|
|
37
|
+
includeBackground: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(results).toHaveLength(3);
|
|
41
|
+
|
|
42
|
+
for (const result of results) {
|
|
43
|
+
expect(result.width).toBe(1024);
|
|
44
|
+
expect(result.height).toBe(1024);
|
|
45
|
+
expect(fs.existsSync(result.filePath)).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('names files correctly', async () => {
|
|
50
|
+
const outDir = path.join(tmpDir, 'names');
|
|
51
|
+
fs.mkdirSync(outDir);
|
|
52
|
+
const results = await generateAndroidIcons(testPng, outDir, '#FFFFFF', {
|
|
53
|
+
includeBackground: true,
|
|
54
|
+
});
|
|
55
|
+
const names = results.map((r) => path.basename(r.filePath));
|
|
56
|
+
|
|
57
|
+
expect(names).toContain('adaptive-icon.png');
|
|
58
|
+
expect(names).toContain('android-icon-background.png');
|
|
59
|
+
expect(names).toContain('monochrome-icon.png');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('background uses the specified color', async () => {
|
|
63
|
+
const outDir = path.join(tmpDir, 'bg-color');
|
|
64
|
+
fs.mkdirSync(outDir);
|
|
65
|
+
const results = await generateAndroidIcons(testPng, outDir, '#FF0000', {
|
|
66
|
+
includeBackground: true,
|
|
67
|
+
});
|
|
68
|
+
const bgResult = results.find((r) => r.filePath.includes('background'));
|
|
69
|
+
|
|
70
|
+
expect(bgResult).toBeDefined();
|
|
71
|
+
const { channels } = await sharp(bgResult!.filePath).stats();
|
|
72
|
+
// channels[0]=R, [1]=G, [2]=B - check mean values for solid color
|
|
73
|
+
expect(channels[0].mean).toBeCloseTo(255, -1);
|
|
74
|
+
expect(channels[1].mean).toBeCloseTo(0, -1);
|
|
75
|
+
expect(channels[2].mean).toBeCloseTo(0, -1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { generateFavicon } from '../../src/variants/favicon.js';
|
|
6
|
+
import { createTestPng, createTmpDir, cleanDir } from '../helpers.js';
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let outDir: string;
|
|
10
|
+
let testPng: string;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
tmpDir = createTmpDir();
|
|
14
|
+
outDir = path.join(tmpDir, 'output');
|
|
15
|
+
fs.mkdirSync(outDir);
|
|
16
|
+
testPng = await createTestPng(1024, 1024, tmpDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
cleanDir(tmpDir);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('generateFavicon', () => {
|
|
24
|
+
it('generates favicon.png at 48x48', async () => {
|
|
25
|
+
const result = await generateFavicon(testPng, outDir);
|
|
26
|
+
|
|
27
|
+
expect(result.width).toBe(48);
|
|
28
|
+
expect(result.height).toBe(48);
|
|
29
|
+
expect(result.filePath).toContain('favicon.png');
|
|
30
|
+
expect(fs.existsSync(result.filePath)).toBe(true);
|
|
31
|
+
|
|
32
|
+
const meta = await sharp(result.filePath).metadata();
|
|
33
|
+
expect(meta.format).toBe('png');
|
|
34
|
+
expect(meta.width).toBe(48);
|
|
35
|
+
});
|
|
36
|
+
});
|