ios-app-review-plugin 1.0.0

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 (205) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/actions/ios-review/action.yml +106 -0
  3. package/.github/workflows/ci.yml +103 -0
  4. package/.github/workflows/publish.yml +57 -0
  5. package/CHANGELOG.md +66 -0
  6. package/CONTRIBUTING.md +175 -0
  7. package/LICENSE +21 -0
  8. package/README.md +205 -0
  9. package/bitrise/step.sh +128 -0
  10. package/bitrise/step.yml +101 -0
  11. package/dist/analyzer.d.ts.map +1 -0
  12. package/dist/analyzers/asc-iap.d.ts.map +1 -0
  13. package/dist/analyzers/asc-metadata.d.ts.map +1 -0
  14. package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
  15. package/dist/analyzers/asc-version.d.ts.map +1 -0
  16. package/dist/analyzers/code-scanner.d.ts.map +1 -0
  17. package/dist/analyzers/deprecated-api.d.ts.map +1 -0
  18. package/dist/analyzers/entitlements.d.ts.map +1 -0
  19. package/dist/analyzers/index.d.ts.map +1 -0
  20. package/dist/analyzers/info-plist.d.ts.map +1 -0
  21. package/dist/analyzers/privacy.d.ts.map +1 -0
  22. package/dist/analyzers/private-api.d.ts.map +1 -0
  23. package/dist/analyzers/security.d.ts.map +1 -0
  24. package/dist/analyzers/ui-ux.d.ts.map +1 -0
  25. package/dist/asc/auth.d.ts.map +1 -0
  26. package/dist/asc/client.d.ts.map +1 -0
  27. package/dist/asc/endpoints/apps.d.ts.map +1 -0
  28. package/dist/asc/endpoints/iap.d.ts.map +1 -0
  29. package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
  30. package/dist/asc/endpoints/versions.d.ts.map +1 -0
  31. package/dist/asc/errors.d.ts.map +1 -0
  32. package/dist/asc/index.d.ts.map +1 -0
  33. package/dist/asc/types.d.ts.map +1 -0
  34. package/dist/badge/generator.d.ts.map +1 -0
  35. package/dist/badge/index.d.ts.map +1 -0
  36. package/dist/badge/types.d.ts.map +1 -0
  37. package/dist/cache/file-cache.d.ts.map +1 -0
  38. package/dist/cache/index.d.ts.map +1 -0
  39. package/dist/cache/types.d.ts.map +1 -0
  40. package/dist/cli/commands/help.d.ts.map +1 -0
  41. package/dist/cli/commands/scan.d.ts.map +1 -0
  42. package/dist/cli/commands/version.d.ts.map +1 -0
  43. package/dist/cli/index.d.ts.map +1 -0
  44. package/dist/cli/types.d.ts.map +1 -0
  45. package/dist/git/diff.d.ts.map +1 -0
  46. package/dist/git/index.d.ts.map +1 -0
  47. package/dist/git/types.d.ts.map +1 -0
  48. package/dist/guidelines/database.d.ts.map +1 -0
  49. package/dist/guidelines/index.d.ts.map +1 -0
  50. package/dist/guidelines/matcher.d.ts.map +1 -0
  51. package/dist/guidelines/types.d.ts.map +1 -0
  52. package/dist/history/comparator.d.ts.map +1 -0
  53. package/dist/history/index.d.ts.map +1 -0
  54. package/dist/history/store.d.ts.map +1 -0
  55. package/dist/history/types.d.ts.map +1 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +994 -0
  58. package/dist/parsers/index.d.ts.map +1 -0
  59. package/dist/parsers/plist.d.ts.map +1 -0
  60. package/dist/parsers/xcodeproj.d.ts.map +1 -0
  61. package/dist/progress/index.d.ts.map +1 -0
  62. package/dist/progress/reporter.d.ts.map +1 -0
  63. package/dist/progress/types.d.ts.map +1 -0
  64. package/dist/reports/html.d.ts.map +1 -0
  65. package/dist/reports/index.d.ts.map +1 -0
  66. package/dist/reports/json.d.ts.map +1 -0
  67. package/dist/reports/markdown.d.ts.map +1 -0
  68. package/dist/reports/types.d.ts.map +1 -0
  69. package/dist/rules/engine.d.ts.map +1 -0
  70. package/dist/rules/index.d.ts.map +1 -0
  71. package/dist/rules/loader.d.ts.map +1 -0
  72. package/dist/rules/types.d.ts.map +1 -0
  73. package/dist/types/index.d.ts.map +1 -0
  74. package/docs/ANALYZERS.md +237 -0
  75. package/docs/API.md +308 -0
  76. package/docs/BADGES.md +130 -0
  77. package/docs/CI_CD.md +283 -0
  78. package/docs/CLI.md +140 -0
  79. package/docs/REPORTS.md +212 -0
  80. package/docs/ROADMAP.md +267 -0
  81. package/docs/RULES.md +182 -0
  82. package/docs/SECURITY.md +89 -0
  83. package/docs/TROUBLESHOOTING.md +227 -0
  84. package/docs/tutorials/ASC_SETUP.md +188 -0
  85. package/docs/tutorials/CI_INTEGRATION.md +292 -0
  86. package/docs/tutorials/CUSTOM_RULES.md +291 -0
  87. package/docs/tutorials/GETTING_STARTED.md +226 -0
  88. package/docs/video-scripts/01-introduction.md +106 -0
  89. package/docs/video-scripts/02-cli-usage.md +120 -0
  90. package/docs/video-scripts/03-ci-integration.md +198 -0
  91. package/eslint.config.js +33 -0
  92. package/examples/.ios-review-rules.json +82 -0
  93. package/examples/bitrise-workflow.yml +129 -0
  94. package/examples/fastlane-lane.rb +71 -0
  95. package/examples/github-action.yml +147 -0
  96. package/fastlane/Fastfile.example +114 -0
  97. package/fastlane/README.md +99 -0
  98. package/jest.config.js +36 -0
  99. package/package.json +65 -0
  100. package/scripts/benchmark.ts +112 -0
  101. package/scripts/debug-parser.ts +37 -0
  102. package/scripts/debug-pbxproj.ts +36 -0
  103. package/scripts/debug-specific.ts +47 -0
  104. package/scripts/test-analyze.ts +67 -0
  105. package/scripts/xcode-cloud-review.sh +167 -0
  106. package/src/analyzer.ts +227 -0
  107. package/src/analyzers/asc-iap.ts +300 -0
  108. package/src/analyzers/asc-metadata.ts +326 -0
  109. package/src/analyzers/asc-screenshots.ts +310 -0
  110. package/src/analyzers/asc-version.ts +368 -0
  111. package/src/analyzers/code-scanner.ts +408 -0
  112. package/src/analyzers/deprecated-api.ts +390 -0
  113. package/src/analyzers/entitlements.ts +345 -0
  114. package/src/analyzers/index.ts +12 -0
  115. package/src/analyzers/info-plist.ts +409 -0
  116. package/src/analyzers/privacy.ts +376 -0
  117. package/src/analyzers/private-api.ts +377 -0
  118. package/src/analyzers/security.ts +327 -0
  119. package/src/analyzers/ui-ux.ts +509 -0
  120. package/src/asc/auth.ts +204 -0
  121. package/src/asc/client.ts +258 -0
  122. package/src/asc/endpoints/apps.ts +115 -0
  123. package/src/asc/endpoints/iap.ts +171 -0
  124. package/src/asc/endpoints/screenshots.ts +164 -0
  125. package/src/asc/endpoints/versions.ts +174 -0
  126. package/src/asc/errors.ts +109 -0
  127. package/src/asc/index.ts +108 -0
  128. package/src/asc/types.ts +369 -0
  129. package/src/badge/generator.ts +48 -0
  130. package/src/badge/index.ts +2 -0
  131. package/src/badge/types.ts +5 -0
  132. package/src/cache/file-cache.ts +75 -0
  133. package/src/cache/index.ts +2 -0
  134. package/src/cache/types.ts +10 -0
  135. package/src/cli/commands/help.ts +41 -0
  136. package/src/cli/commands/scan.ts +44 -0
  137. package/src/cli/commands/version.ts +12 -0
  138. package/src/cli/index.ts +92 -0
  139. package/src/cli/types.ts +17 -0
  140. package/src/git/diff.ts +21 -0
  141. package/src/git/index.ts +2 -0
  142. package/src/git/types.ts +5 -0
  143. package/src/guidelines/database.ts +344 -0
  144. package/src/guidelines/index.ts +4 -0
  145. package/src/guidelines/matcher.ts +84 -0
  146. package/src/guidelines/types.ts +28 -0
  147. package/src/history/comparator.ts +114 -0
  148. package/src/history/index.ts +3 -0
  149. package/src/history/store.ts +135 -0
  150. package/src/history/types.ts +40 -0
  151. package/src/index.ts +1113 -0
  152. package/src/parsers/index.ts +3 -0
  153. package/src/parsers/plist.ts +253 -0
  154. package/src/parsers/xcodeproj.ts +265 -0
  155. package/src/progress/index.ts +2 -0
  156. package/src/progress/reporter.ts +65 -0
  157. package/src/progress/types.ts +9 -0
  158. package/src/reports/html.ts +322 -0
  159. package/src/reports/index.ts +20 -0
  160. package/src/reports/json.ts +92 -0
  161. package/src/reports/markdown.ts +187 -0
  162. package/src/reports/types.ts +26 -0
  163. package/src/rules/engine.ts +121 -0
  164. package/src/rules/index.ts +3 -0
  165. package/src/rules/loader.ts +83 -0
  166. package/src/rules/types.ts +25 -0
  167. package/src/types/index.ts +247 -0
  168. package/tests/analyzer.test.ts +142 -0
  169. package/tests/analyzers/asc-iap.test.ts +228 -0
  170. package/tests/analyzers/asc-metadata.test.ts +210 -0
  171. package/tests/analyzers/asc-screenshots.test.ts +135 -0
  172. package/tests/analyzers/asc-version.test.ts +259 -0
  173. package/tests/analyzers/code-scanner.test.ts +745 -0
  174. package/tests/analyzers/deprecated-api.test.ts +286 -0
  175. package/tests/analyzers/entitlements.test.ts +411 -0
  176. package/tests/analyzers/info-plist.test.ts +148 -0
  177. package/tests/analyzers/privacy.test.ts +623 -0
  178. package/tests/analyzers/private-api.test.ts +255 -0
  179. package/tests/analyzers/security.test.ts +300 -0
  180. package/tests/analyzers/ui-ux.test.ts +357 -0
  181. package/tests/asc/auth.test.ts +189 -0
  182. package/tests/asc/client.test.ts +207 -0
  183. package/tests/asc/endpoints.test.ts +1359 -0
  184. package/tests/badge/generator.test.ts +73 -0
  185. package/tests/cache/file-cache.test.ts +124 -0
  186. package/tests/cli/cli-index.test.ts +510 -0
  187. package/tests/cli/commands.test.ts +67 -0
  188. package/tests/cli/scan.test.ts +152 -0
  189. package/tests/git/diff.test.ts +69 -0
  190. package/tests/guidelines/matcher.test.ts +209 -0
  191. package/tests/history/comparator.test.ts +272 -0
  192. package/tests/history/store.test.ts +200 -0
  193. package/tests/integration/cli.test.ts +95 -0
  194. package/tests/integration/e2e.test.ts +130 -0
  195. package/tests/parsers/plist.test.ts +240 -0
  196. package/tests/parsers/xcodeproj.test.ts +289 -0
  197. package/tests/progress/reporter.test.ts +117 -0
  198. package/tests/reports/html.test.ts +176 -0
  199. package/tests/reports/json.test.ts +235 -0
  200. package/tests/reports/markdown.test.ts +196 -0
  201. package/tests/rules/engine.test.ts +229 -0
  202. package/tests/rules/loader.test.ts +187 -0
  203. package/tests/setup.ts +15 -0
  204. package/tsconfig.json +27 -0
  205. package/tsconfig.test.json +9 -0
@@ -0,0 +1,357 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { UIUXAnalyzer } from '../../src/analyzers/ui-ux.js';
5
+ import type { XcodeProject, XcodeTarget } from '../../src/types/index.js';
6
+
7
+ jest.mock('../../src/parsers/plist', () => ({
8
+ parsePlist: jest.fn(),
9
+ }));
10
+
11
+ const mockParsePlist = jest.requireMock('../../src/parsers/plist').parsePlist as jest.Mock;
12
+
13
+ describe('UIUXAnalyzer', () => {
14
+ let analyzer: UIUXAnalyzer;
15
+ let tempDir: string;
16
+
17
+ beforeAll(async () => {
18
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'uiux-test-'));
19
+ });
20
+
21
+ afterAll(async () => {
22
+ await fs.rm(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ beforeEach(() => {
26
+ analyzer = new UIUXAnalyzer();
27
+ jest.clearAllMocks();
28
+ });
29
+
30
+ function makeProject(overrides?: Partial<XcodeTarget>): XcodeProject {
31
+ return {
32
+ path: '/test/TestApp.xcodeproj',
33
+ name: 'TestApp',
34
+ targets: [
35
+ {
36
+ name: 'TestApp',
37
+ type: 'application',
38
+ bundleIdentifier: 'com.test.app',
39
+ infoPlistPath: path.join(tempDir, 'Info.plist'),
40
+ sourceFiles: [],
41
+ ...overrides,
42
+ },
43
+ ],
44
+ configurations: ['Debug', 'Release'],
45
+ };
46
+ }
47
+
48
+ describe('launch screen checks', () => {
49
+ it('should pass when UILaunchStoryboardName is set', async () => {
50
+ mockParsePlist.mockResolvedValue({
51
+ UILaunchStoryboardName: 'LaunchScreen',
52
+ });
53
+
54
+ const result = await analyzer.analyze(makeProject(), {
55
+ basePath: tempDir,
56
+ });
57
+
58
+ expect(result.issues.some((i) => i.id === 'uiux-no-launch-screen')).toBe(false);
59
+ });
60
+
61
+ it('should detect missing launch screen', async () => {
62
+ mockParsePlist.mockResolvedValue({});
63
+
64
+ const result = await analyzer.analyze(makeProject(), {
65
+ basePath: tempDir,
66
+ });
67
+
68
+ expect(result.issues.some((i) => i.id === 'uiux-no-launch-screen')).toBe(true);
69
+ expect(result.issues.find((i) => i.id === 'uiux-no-launch-screen')?.severity).toBe('error');
70
+ });
71
+
72
+ it('should pass when LaunchScreen.storyboard file exists', async () => {
73
+ mockParsePlist.mockResolvedValue({});
74
+
75
+ const launchDir = path.join(tempDir, 'LaunchScreenDir');
76
+ await fs.mkdir(launchDir, { recursive: true });
77
+ await fs.writeFile(path.join(launchDir, 'LaunchScreen.storyboard'), '<xml></xml>');
78
+
79
+ const result = await analyzer.analyze(makeProject(), {
80
+ basePath: launchDir,
81
+ });
82
+
83
+ expect(result.issues.some((i) => i.id === 'uiux-no-launch-screen')).toBe(false);
84
+
85
+ await fs.rm(launchDir, { recursive: true, force: true });
86
+ });
87
+ });
88
+
89
+ describe('app icon checks', () => {
90
+ it('should detect missing app icon asset catalog', async () => {
91
+ mockParsePlist.mockResolvedValue({
92
+ UILaunchStoryboardName: 'LaunchScreen',
93
+ });
94
+
95
+ const result = await analyzer.analyze(makeProject(), {
96
+ basePath: tempDir,
97
+ });
98
+
99
+ expect(result.issues.some((i) => i.id === 'uiux-no-app-icon')).toBe(true);
100
+ });
101
+
102
+ it('should detect missing 1024x1024 App Store icon', async () => {
103
+ mockParsePlist.mockResolvedValue({
104
+ UILaunchStoryboardName: 'LaunchScreen',
105
+ });
106
+
107
+ const iconDir = path.join(tempDir, 'IconCheck', 'Assets.xcassets', 'AppIcon.appiconset');
108
+ await fs.mkdir(iconDir, { recursive: true });
109
+ await fs.writeFile(
110
+ path.join(iconDir, 'Contents.json'),
111
+ JSON.stringify({
112
+ images: [
113
+ { size: '60x60', scale: '2x', filename: 'icon-120.png', idiom: 'iphone' },
114
+ ],
115
+ })
116
+ );
117
+
118
+ const result = await analyzer.analyze(makeProject(), {
119
+ basePath: path.join(tempDir, 'IconCheck'),
120
+ });
121
+
122
+ expect(result.issues.some((i) => i.id === 'uiux-missing-appstore-icon')).toBe(true);
123
+
124
+ await fs.rm(path.join(tempDir, 'IconCheck'), { recursive: true, force: true });
125
+ });
126
+
127
+ it('should pass when 1024x1024 icon exists', async () => {
128
+ mockParsePlist.mockResolvedValue({
129
+ UILaunchStoryboardName: 'LaunchScreen',
130
+ });
131
+
132
+ const iconDir = path.join(tempDir, 'IconPass', 'Assets.xcassets', 'AppIcon.appiconset');
133
+ await fs.mkdir(iconDir, { recursive: true });
134
+ await fs.writeFile(
135
+ path.join(iconDir, 'Contents.json'),
136
+ JSON.stringify({
137
+ images: [
138
+ { size: '1024x1024', scale: '1x', filename: 'appstore-icon.png', idiom: 'ios-marketing' },
139
+ { size: '60x60', scale: '2x', filename: 'icon-120.png', idiom: 'iphone' },
140
+ { size: '60x60', scale: '3x', filename: 'icon-180.png', idiom: 'iphone' },
141
+ ],
142
+ })
143
+ );
144
+
145
+ const result = await analyzer.analyze(makeProject(), {
146
+ basePath: path.join(tempDir, 'IconPass'),
147
+ });
148
+
149
+ expect(result.issues.some((i) => i.id === 'uiux-missing-appstore-icon')).toBe(false);
150
+
151
+ await fs.rm(path.join(tempDir, 'IconPass'), { recursive: true, force: true });
152
+ });
153
+ });
154
+
155
+ describe('iPad support checks', () => {
156
+ it('should detect missing iPad orientations', async () => {
157
+ mockParsePlist.mockResolvedValue({
158
+ UILaunchStoryboardName: 'LaunchScreen',
159
+ UIDeviceFamily: [1, 2],
160
+ 'UISupportedInterfaceOrientations~ipad': [
161
+ 'UIInterfaceOrientationPortrait',
162
+ 'UIInterfaceOrientationLandscapeLeft',
163
+ ],
164
+ });
165
+
166
+ const result = await analyzer.analyze(makeProject(), {
167
+ basePath: tempDir,
168
+ });
169
+
170
+ expect(result.issues.some((i) => i.id === 'uiux-ipad-missing-orientations')).toBe(true);
171
+ });
172
+
173
+ it('should pass when all iPad orientations are present', async () => {
174
+ mockParsePlist.mockResolvedValue({
175
+ UILaunchStoryboardName: 'LaunchScreen',
176
+ UIDeviceFamily: [1, 2],
177
+ 'UISupportedInterfaceOrientations~ipad': [
178
+ 'UIInterfaceOrientationPortrait',
179
+ 'UIInterfaceOrientationPortraitUpsideDown',
180
+ 'UIInterfaceOrientationLandscapeLeft',
181
+ 'UIInterfaceOrientationLandscapeRight',
182
+ ],
183
+ });
184
+
185
+ const result = await analyzer.analyze(makeProject(), {
186
+ basePath: tempDir,
187
+ });
188
+
189
+ expect(result.issues.some((i) => i.id === 'uiux-ipad-missing-orientations')).toBe(false);
190
+ });
191
+
192
+ it('should not check iPad orientations for iPhone-only apps', async () => {
193
+ mockParsePlist.mockResolvedValue({
194
+ UILaunchStoryboardName: 'LaunchScreen',
195
+ UIDeviceFamily: [1],
196
+ });
197
+
198
+ const result = await analyzer.analyze(makeProject(), {
199
+ basePath: tempDir,
200
+ });
201
+
202
+ expect(result.issues.some((i) => i.id === 'uiux-ipad-missing-orientations')).toBe(false);
203
+ });
204
+ });
205
+
206
+ describe('placeholder text checks', () => {
207
+ it('should detect placeholder text in storyboards', async () => {
208
+ const storyDir = path.join(tempDir, 'PlaceholderDir');
209
+ await fs.mkdir(storyDir, { recursive: true });
210
+ await fs.writeFile(
211
+ path.join(storyDir, 'Main.storyboard'),
212
+ `<?xml version="1.0" encoding="UTF-8"?>
213
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB">
214
+ <label text="Lorem ipsum dolor sit amet" id="abc-123"/>
215
+ <label text="Label" id="def-456"/>
216
+ </document>`
217
+ );
218
+
219
+ mockParsePlist.mockResolvedValue({
220
+ UILaunchStoryboardName: 'LaunchScreen',
221
+ });
222
+
223
+ const result = await analyzer.analyze(makeProject(), {
224
+ basePath: storyDir,
225
+ });
226
+
227
+ expect(result.issues.some((i) => i.id === 'uiux-placeholder-text')).toBe(true);
228
+
229
+ await fs.rm(storyDir, { recursive: true, force: true });
230
+ });
231
+
232
+ it('should not flag storyboards with real content', async () => {
233
+ const storyDir = path.join(tempDir, 'RealContentDir');
234
+ await fs.mkdir(storyDir, { recursive: true });
235
+ await fs.writeFile(
236
+ path.join(storyDir, 'Main.storyboard'),
237
+ `<?xml version="1.0" encoding="UTF-8"?>
238
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB">
239
+ <label text="Welcome to My App" id="abc-123"/>
240
+ <button title="Get Started" id="def-456"/>
241
+ </document>`
242
+ );
243
+
244
+ mockParsePlist.mockResolvedValue({
245
+ UILaunchStoryboardName: 'LaunchScreen',
246
+ });
247
+
248
+ const result = await analyzer.analyze(makeProject(), {
249
+ basePath: storyDir,
250
+ });
251
+
252
+ expect(result.issues.some((i) => i.id === 'uiux-placeholder-text')).toBe(false);
253
+
254
+ await fs.rm(storyDir, { recursive: true, force: true });
255
+ });
256
+ });
257
+
258
+ describe('accessibility checks', () => {
259
+ it('should warn when images are used without accessibility labels', async () => {
260
+ const srcDir = path.join(tempDir, 'AccessibilityDir');
261
+ await fs.mkdir(srcDir, { recursive: true });
262
+ await fs.writeFile(
263
+ path.join(srcDir, 'ViewController.swift'),
264
+ `import UIKit
265
+
266
+ class ViewController: UIViewController {
267
+ let imageView = UIImageView(image: UIImage(named: "photo"))
268
+ let icon = UIImage(named: "icon")
269
+ }
270
+ `
271
+ );
272
+
273
+ mockParsePlist.mockResolvedValue({
274
+ UILaunchStoryboardName: 'LaunchScreen',
275
+ });
276
+
277
+ const result = await analyzer.analyze(makeProject(), {
278
+ basePath: srcDir,
279
+ });
280
+
281
+ expect(result.issues.some((i) => i.id === 'uiux-no-accessibility-labels')).toBe(true);
282
+
283
+ await fs.rm(srcDir, { recursive: true, force: true });
284
+ });
285
+
286
+ it('should not warn when accessibility labels are present', async () => {
287
+ const srcDir = path.join(tempDir, 'AccessibleDir');
288
+ await fs.mkdir(srcDir, { recursive: true });
289
+ await fs.writeFile(
290
+ path.join(srcDir, 'ViewController.swift'),
291
+ `import UIKit
292
+
293
+ class ViewController: UIViewController {
294
+ let imageView = UIImageView(image: UIImage(named: "photo"))
295
+
296
+ func setup() {
297
+ imageView.accessibilityLabel = "User photo"
298
+ }
299
+ }
300
+ `
301
+ );
302
+
303
+ mockParsePlist.mockResolvedValue({
304
+ UILaunchStoryboardName: 'LaunchScreen',
305
+ });
306
+
307
+ const result = await analyzer.analyze(makeProject(), {
308
+ basePath: srcDir,
309
+ });
310
+
311
+ expect(result.issues.some((i) => i.id === 'uiux-no-accessibility-labels')).toBe(false);
312
+
313
+ await fs.rm(srcDir, { recursive: true, force: true });
314
+ });
315
+
316
+ it('should note when hardcoded fonts are used without Dynamic Type', async () => {
317
+ const srcDir = path.join(tempDir, 'FontDir');
318
+ await fs.mkdir(srcDir, { recursive: true });
319
+ await fs.writeFile(
320
+ path.join(srcDir, 'ViewController.swift'),
321
+ `import UIKit
322
+
323
+ class ViewController: UIViewController {
324
+ let label = UILabel()
325
+
326
+ func setup() {
327
+ label.font = UIFont.systemFont(ofSize: 16)
328
+ }
329
+ }
330
+ `
331
+ );
332
+
333
+ mockParsePlist.mockResolvedValue({
334
+ UILaunchStoryboardName: 'LaunchScreen',
335
+ });
336
+
337
+ const result = await analyzer.analyze(makeProject(), {
338
+ basePath: srcDir,
339
+ });
340
+
341
+ expect(result.issues.some((i) => i.id === 'uiux-no-dynamic-type')).toBe(true);
342
+
343
+ await fs.rm(srcDir, { recursive: true, force: true });
344
+ });
345
+ });
346
+
347
+ describe('validateProject', () => {
348
+ it('should work with direct project path', async () => {
349
+ mockParsePlist.mockResolvedValue({
350
+ UILaunchStoryboardName: 'LaunchScreen',
351
+ });
352
+
353
+ const result = await analyzer.validateProject(tempDir);
354
+ expect(result.analyzer).toBe('UI/UX Compliance');
355
+ });
356
+ });
357
+ });
@@ -0,0 +1,189 @@
1
+ import * as crypto from 'crypto';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { getToken, clearTokenCache, hasCredentials } from '../../src/asc/auth';
6
+
7
+ // Store original env vars
8
+ const originalEnv = { ...process.env };
9
+
10
+ // Generate a test EC P-256 key pair
11
+ function generateTestKeyPair(): { privateKey: string; publicKey: string } {
12
+ const keyPair = crypto.generateKeyPairSync('ec', {
13
+ namedCurve: 'P-256',
14
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
15
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
16
+ });
17
+ return keyPair;
18
+ }
19
+
20
+ describe('ASC Auth', () => {
21
+ let tempDir: string;
22
+ let testKeyPair: { privateKey: string; publicKey: string };
23
+
24
+ beforeAll(async () => {
25
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'asc-auth-test-'));
26
+ testKeyPair = generateTestKeyPair();
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await fs.rm(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ beforeEach(() => {
34
+ // Clear cached credentials/token between tests
35
+ clearTokenCache();
36
+ });
37
+
38
+ afterEach(() => {
39
+ // Restore env vars
40
+ process.env = { ...originalEnv };
41
+ clearTokenCache();
42
+ });
43
+
44
+ describe('hasCredentials', () => {
45
+ it('should return false when no env vars are set', () => {
46
+ delete process.env['ASC_KEY_ID'];
47
+ delete process.env['ASC_ISSUER_ID'];
48
+ delete process.env['ASC_PRIVATE_KEY_PATH'];
49
+ delete process.env['ASC_PRIVATE_KEY'];
50
+
51
+ expect(hasCredentials()).toBe(false);
52
+ });
53
+
54
+ it('should return false when only some env vars are set', () => {
55
+ process.env['ASC_KEY_ID'] = 'test-key-id';
56
+ delete process.env['ASC_ISSUER_ID'];
57
+ delete process.env['ASC_PRIVATE_KEY_PATH'];
58
+ delete process.env['ASC_PRIVATE_KEY'];
59
+
60
+ expect(hasCredentials()).toBe(false);
61
+ });
62
+
63
+ it('should return true when all env vars are set with key path', () => {
64
+ process.env['ASC_KEY_ID'] = 'test-key-id';
65
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
66
+ process.env['ASC_PRIVATE_KEY_PATH'] = '/path/to/key.p8';
67
+
68
+ expect(hasCredentials()).toBe(true);
69
+ });
70
+
71
+ it('should return true when using inline private key', () => {
72
+ process.env['ASC_KEY_ID'] = 'test-key-id';
73
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
74
+ process.env['ASC_PRIVATE_KEY'] = testKeyPair.privateKey;
75
+
76
+ expect(hasCredentials()).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe('getToken', () => {
81
+ it('should generate a valid JWT token', async () => {
82
+ const keyPath = path.join(tempDir, 'test.p8');
83
+ await fs.writeFile(keyPath, testKeyPair.privateKey);
84
+
85
+ process.env['ASC_KEY_ID'] = 'test-key-id';
86
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
87
+ process.env['ASC_PRIVATE_KEY_PATH'] = keyPath;
88
+
89
+ const token = await getToken();
90
+
91
+ // JWT should have 3 parts
92
+ const parts = token.split('.');
93
+ expect(parts).toHaveLength(3);
94
+
95
+ // Decode header
96
+ const header = JSON.parse(Buffer.from(parts[0]!, 'base64url').toString());
97
+ expect(header.alg).toBe('ES256');
98
+ expect(header.kid).toBe('test-key-id');
99
+ expect(header.typ).toBe('JWT');
100
+
101
+ // Decode payload
102
+ const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString());
103
+ expect(payload.iss).toBe('test-issuer-id');
104
+ expect(payload.aud).toBe('appstoreconnect-v1');
105
+ expect(payload.exp).toBeGreaterThan(payload.iat);
106
+ });
107
+
108
+ it('should cache tokens', async () => {
109
+ const keyPath = path.join(tempDir, 'test-cache.p8');
110
+ await fs.writeFile(keyPath, testKeyPair.privateKey);
111
+
112
+ process.env['ASC_KEY_ID'] = 'test-key-id';
113
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
114
+ process.env['ASC_PRIVATE_KEY_PATH'] = keyPath;
115
+
116
+ const token1 = await getToken();
117
+ const token2 = await getToken();
118
+
119
+ // Same token should be returned (cached)
120
+ expect(token1).toBe(token2);
121
+ });
122
+
123
+ it('should throw when credentials are not configured', async () => {
124
+ delete process.env['ASC_KEY_ID'];
125
+ delete process.env['ASC_ISSUER_ID'];
126
+ delete process.env['ASC_PRIVATE_KEY_PATH'];
127
+ delete process.env['ASC_PRIVATE_KEY'];
128
+
129
+ await expect(getToken()).rejects.toThrow('credentials not configured');
130
+ });
131
+
132
+ it('should work with inline private key', async () => {
133
+ process.env['ASC_KEY_ID'] = 'test-key-id';
134
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
135
+ process.env['ASC_PRIVATE_KEY'] = testKeyPair.privateKey;
136
+
137
+ const token = await getToken();
138
+ expect(token.split('.')).toHaveLength(3);
139
+ });
140
+
141
+ it('should handle escaped newlines in inline key', async () => {
142
+ const escapedKey = testKeyPair.privateKey.replace(/\n/g, '\\n');
143
+ process.env['ASC_KEY_ID'] = 'test-key-id';
144
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
145
+ process.env['ASC_PRIVATE_KEY'] = escapedKey;
146
+
147
+ const token = await getToken();
148
+ expect(token.split('.')).toHaveLength(3);
149
+ });
150
+
151
+ it('should throw for invalid key file path', async () => {
152
+ process.env['ASC_KEY_ID'] = 'test-key-id';
153
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
154
+ process.env['ASC_PRIVATE_KEY_PATH'] = '/nonexistent/key.p8';
155
+
156
+ await expect(getToken()).rejects.toThrow('Failed to read private key');
157
+ });
158
+
159
+ it('should throw for invalid key format', async () => {
160
+ const keyPath = path.join(tempDir, 'bad.p8');
161
+ await fs.writeFile(keyPath, 'not-a-valid-key');
162
+
163
+ process.env['ASC_KEY_ID'] = 'test-key-id';
164
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
165
+ process.env['ASC_PRIVATE_KEY_PATH'] = keyPath;
166
+
167
+ await expect(getToken()).rejects.toThrow('Invalid private key format');
168
+ });
169
+ });
170
+
171
+ describe('clearTokenCache', () => {
172
+ it('should clear cached credentials and token', async () => {
173
+ const keyPath = path.join(tempDir, 'test-clear.p8');
174
+ await fs.writeFile(keyPath, testKeyPair.privateKey);
175
+
176
+ process.env['ASC_KEY_ID'] = 'test-key-id';
177
+ process.env['ASC_ISSUER_ID'] = 'test-issuer-id';
178
+ process.env['ASC_PRIVATE_KEY_PATH'] = keyPath;
179
+
180
+ const token1 = await getToken();
181
+ clearTokenCache();
182
+ const token2 = await getToken();
183
+
184
+ // Both tokens should be valid
185
+ expect(token1).toBeDefined();
186
+ expect(token2).toBeDefined();
187
+ });
188
+ });
189
+ });