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.
- package/.claude/settings.local.json +42 -0
- package/.github/actions/ios-review/action.yml +106 -0
- package/.github/workflows/ci.yml +103 -0
- package/.github/workflows/publish.yml +57 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +175 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bitrise/step.sh +128 -0
- package/bitrise/step.yml +101 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzers/asc-iap.d.ts.map +1 -0
- package/dist/analyzers/asc-metadata.d.ts.map +1 -0
- package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
- package/dist/analyzers/asc-version.d.ts.map +1 -0
- package/dist/analyzers/code-scanner.d.ts.map +1 -0
- package/dist/analyzers/deprecated-api.d.ts.map +1 -0
- package/dist/analyzers/entitlements.d.ts.map +1 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/info-plist.d.ts.map +1 -0
- package/dist/analyzers/privacy.d.ts.map +1 -0
- package/dist/analyzers/private-api.d.ts.map +1 -0
- package/dist/analyzers/security.d.ts.map +1 -0
- package/dist/analyzers/ui-ux.d.ts.map +1 -0
- package/dist/asc/auth.d.ts.map +1 -0
- package/dist/asc/client.d.ts.map +1 -0
- package/dist/asc/endpoints/apps.d.ts.map +1 -0
- package/dist/asc/endpoints/iap.d.ts.map +1 -0
- package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
- package/dist/asc/endpoints/versions.d.ts.map +1 -0
- package/dist/asc/errors.d.ts.map +1 -0
- package/dist/asc/index.d.ts.map +1 -0
- package/dist/asc/types.d.ts.map +1 -0
- package/dist/badge/generator.d.ts.map +1 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/types.d.ts.map +1 -0
- package/dist/cache/file-cache.d.ts.map +1 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/scan.d.ts.map +1 -0
- package/dist/cli/commands/version.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/git/diff.d.ts.map +1 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/types.d.ts.map +1 -0
- package/dist/guidelines/database.d.ts.map +1 -0
- package/dist/guidelines/index.d.ts.map +1 -0
- package/dist/guidelines/matcher.d.ts.map +1 -0
- package/dist/guidelines/types.d.ts.map +1 -0
- package/dist/history/comparator.d.ts.map +1 -0
- package/dist/history/index.d.ts.map +1 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +994 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/plist.d.ts.map +1 -0
- package/dist/parsers/xcodeproj.d.ts.map +1 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/reporter.d.ts.map +1 -0
- package/dist/progress/types.d.ts.map +1 -0
- package/dist/reports/html.d.ts.map +1 -0
- package/dist/reports/index.d.ts.map +1 -0
- package/dist/reports/json.d.ts.map +1 -0
- package/dist/reports/markdown.d.ts.map +1 -0
- package/dist/reports/types.d.ts.map +1 -0
- package/dist/rules/engine.d.ts.map +1 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/loader.d.ts.map +1 -0
- package/dist/rules/types.d.ts.map +1 -0
- package/dist/types/index.d.ts.map +1 -0
- package/docs/ANALYZERS.md +237 -0
- package/docs/API.md +308 -0
- package/docs/BADGES.md +130 -0
- package/docs/CI_CD.md +283 -0
- package/docs/CLI.md +140 -0
- package/docs/REPORTS.md +212 -0
- package/docs/ROADMAP.md +267 -0
- package/docs/RULES.md +182 -0
- package/docs/SECURITY.md +89 -0
- package/docs/TROUBLESHOOTING.md +227 -0
- package/docs/tutorials/ASC_SETUP.md +188 -0
- package/docs/tutorials/CI_INTEGRATION.md +292 -0
- package/docs/tutorials/CUSTOM_RULES.md +291 -0
- package/docs/tutorials/GETTING_STARTED.md +226 -0
- package/docs/video-scripts/01-introduction.md +106 -0
- package/docs/video-scripts/02-cli-usage.md +120 -0
- package/docs/video-scripts/03-ci-integration.md +198 -0
- package/eslint.config.js +33 -0
- package/examples/.ios-review-rules.json +82 -0
- package/examples/bitrise-workflow.yml +129 -0
- package/examples/fastlane-lane.rb +71 -0
- package/examples/github-action.yml +147 -0
- package/fastlane/Fastfile.example +114 -0
- package/fastlane/README.md +99 -0
- package/jest.config.js +36 -0
- package/package.json +65 -0
- package/scripts/benchmark.ts +112 -0
- package/scripts/debug-parser.ts +37 -0
- package/scripts/debug-pbxproj.ts +36 -0
- package/scripts/debug-specific.ts +47 -0
- package/scripts/test-analyze.ts +67 -0
- package/scripts/xcode-cloud-review.sh +167 -0
- package/src/analyzer.ts +227 -0
- package/src/analyzers/asc-iap.ts +300 -0
- package/src/analyzers/asc-metadata.ts +326 -0
- package/src/analyzers/asc-screenshots.ts +310 -0
- package/src/analyzers/asc-version.ts +368 -0
- package/src/analyzers/code-scanner.ts +408 -0
- package/src/analyzers/deprecated-api.ts +390 -0
- package/src/analyzers/entitlements.ts +345 -0
- package/src/analyzers/index.ts +12 -0
- package/src/analyzers/info-plist.ts +409 -0
- package/src/analyzers/privacy.ts +376 -0
- package/src/analyzers/private-api.ts +377 -0
- package/src/analyzers/security.ts +327 -0
- package/src/analyzers/ui-ux.ts +509 -0
- package/src/asc/auth.ts +204 -0
- package/src/asc/client.ts +258 -0
- package/src/asc/endpoints/apps.ts +115 -0
- package/src/asc/endpoints/iap.ts +171 -0
- package/src/asc/endpoints/screenshots.ts +164 -0
- package/src/asc/endpoints/versions.ts +174 -0
- package/src/asc/errors.ts +109 -0
- package/src/asc/index.ts +108 -0
- package/src/asc/types.ts +369 -0
- package/src/badge/generator.ts +48 -0
- package/src/badge/index.ts +2 -0
- package/src/badge/types.ts +5 -0
- package/src/cache/file-cache.ts +75 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/types.ts +10 -0
- package/src/cli/commands/help.ts +41 -0
- package/src/cli/commands/scan.ts +44 -0
- package/src/cli/commands/version.ts +12 -0
- package/src/cli/index.ts +92 -0
- package/src/cli/types.ts +17 -0
- package/src/git/diff.ts +21 -0
- package/src/git/index.ts +2 -0
- package/src/git/types.ts +5 -0
- package/src/guidelines/database.ts +344 -0
- package/src/guidelines/index.ts +4 -0
- package/src/guidelines/matcher.ts +84 -0
- package/src/guidelines/types.ts +28 -0
- package/src/history/comparator.ts +114 -0
- package/src/history/index.ts +3 -0
- package/src/history/store.ts +135 -0
- package/src/history/types.ts +40 -0
- package/src/index.ts +1113 -0
- package/src/parsers/index.ts +3 -0
- package/src/parsers/plist.ts +253 -0
- package/src/parsers/xcodeproj.ts +265 -0
- package/src/progress/index.ts +2 -0
- package/src/progress/reporter.ts +65 -0
- package/src/progress/types.ts +9 -0
- package/src/reports/html.ts +322 -0
- package/src/reports/index.ts +20 -0
- package/src/reports/json.ts +92 -0
- package/src/reports/markdown.ts +187 -0
- package/src/reports/types.ts +26 -0
- package/src/rules/engine.ts +121 -0
- package/src/rules/index.ts +3 -0
- package/src/rules/loader.ts +83 -0
- package/src/rules/types.ts +25 -0
- package/src/types/index.ts +247 -0
- package/tests/analyzer.test.ts +142 -0
- package/tests/analyzers/asc-iap.test.ts +228 -0
- package/tests/analyzers/asc-metadata.test.ts +210 -0
- package/tests/analyzers/asc-screenshots.test.ts +135 -0
- package/tests/analyzers/asc-version.test.ts +259 -0
- package/tests/analyzers/code-scanner.test.ts +745 -0
- package/tests/analyzers/deprecated-api.test.ts +286 -0
- package/tests/analyzers/entitlements.test.ts +411 -0
- package/tests/analyzers/info-plist.test.ts +148 -0
- package/tests/analyzers/privacy.test.ts +623 -0
- package/tests/analyzers/private-api.test.ts +255 -0
- package/tests/analyzers/security.test.ts +300 -0
- package/tests/analyzers/ui-ux.test.ts +357 -0
- package/tests/asc/auth.test.ts +189 -0
- package/tests/asc/client.test.ts +207 -0
- package/tests/asc/endpoints.test.ts +1359 -0
- package/tests/badge/generator.test.ts +73 -0
- package/tests/cache/file-cache.test.ts +124 -0
- package/tests/cli/cli-index.test.ts +510 -0
- package/tests/cli/commands.test.ts +67 -0
- package/tests/cli/scan.test.ts +152 -0
- package/tests/git/diff.test.ts +69 -0
- package/tests/guidelines/matcher.test.ts +209 -0
- package/tests/history/comparator.test.ts +272 -0
- package/tests/history/store.test.ts +200 -0
- package/tests/integration/cli.test.ts +95 -0
- package/tests/integration/e2e.test.ts +130 -0
- package/tests/parsers/plist.test.ts +240 -0
- package/tests/parsers/xcodeproj.test.ts +289 -0
- package/tests/progress/reporter.test.ts +117 -0
- package/tests/reports/html.test.ts +176 -0
- package/tests/reports/json.test.ts +235 -0
- package/tests/reports/markdown.test.ts +196 -0
- package/tests/rules/engine.test.ts +229 -0
- package/tests/rules/loader.test.ts +187 -0
- package/tests/setup.ts +15 -0
- package/tsconfig.json +27 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Store Connect Metadata Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Validates app metadata from ASC including app name, subtitle,
|
|
5
|
+
* description, keywords, URLs, and checks for placeholder text.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Analyzer,
|
|
10
|
+
AnalysisResult,
|
|
11
|
+
AnalyzerOptions,
|
|
12
|
+
Issue,
|
|
13
|
+
XcodeProject,
|
|
14
|
+
} from '../types/index.js';
|
|
15
|
+
import {
|
|
16
|
+
hasCredentials,
|
|
17
|
+
getAppWithInfo,
|
|
18
|
+
isASCError,
|
|
19
|
+
type AppInfoLocalization,
|
|
20
|
+
} from '../asc/index.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Metadata length limits from App Store Connect
|
|
24
|
+
*/
|
|
25
|
+
const LIMITS = {
|
|
26
|
+
appName: 30,
|
|
27
|
+
subtitle: 30,
|
|
28
|
+
description: 4000,
|
|
29
|
+
keywords: 100,
|
|
30
|
+
promotionalText: 170,
|
|
31
|
+
whatsNew: 4000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Placeholder text patterns
|
|
36
|
+
*/
|
|
37
|
+
const PLACEHOLDER_PATTERNS = [
|
|
38
|
+
/lorem\s+ipsum/i,
|
|
39
|
+
/^todo/i,
|
|
40
|
+
/^fixme/i,
|
|
41
|
+
/^\[.*\]$/,
|
|
42
|
+
/^<.*>$/,
|
|
43
|
+
/^placeholder/i,
|
|
44
|
+
/your\s+(app\s+)?description/i,
|
|
45
|
+
/enter\s+(your\s+)?description/i,
|
|
46
|
+
/add\s+(your\s+)?description/i,
|
|
47
|
+
/sample\s+text/i,
|
|
48
|
+
/example\s+text/i,
|
|
49
|
+
/test\s+(app|description)/i,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if text contains placeholder content
|
|
54
|
+
*/
|
|
55
|
+
function isPlaceholder(text: string): boolean {
|
|
56
|
+
return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(text.trim()));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate a URL format
|
|
61
|
+
*/
|
|
62
|
+
function isValidUrl(url: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = new URL(url);
|
|
65
|
+
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class ASCMetadataAnalyzer implements Analyzer {
|
|
72
|
+
name = 'ASC Metadata Analyzer';
|
|
73
|
+
description = 'Validates app metadata in App Store Connect';
|
|
74
|
+
|
|
75
|
+
async analyze(project: XcodeProject, options?: AnalyzerOptions): Promise<AnalysisResult> {
|
|
76
|
+
const startTime = Date.now();
|
|
77
|
+
const issues: Issue[] = [];
|
|
78
|
+
|
|
79
|
+
// Check if credentials are configured
|
|
80
|
+
if (!hasCredentials()) {
|
|
81
|
+
issues.push({
|
|
82
|
+
id: 'asc-credentials-not-configured',
|
|
83
|
+
title: 'App Store Connect credentials not configured',
|
|
84
|
+
description:
|
|
85
|
+
'ASC credentials are not configured. Set ASC_KEY_ID, ASC_ISSUER_ID, and ASC_PRIVATE_KEY_PATH (or ASC_PRIVATE_KEY) environment variables to enable ASC validation.',
|
|
86
|
+
severity: 'info',
|
|
87
|
+
category: 'metadata',
|
|
88
|
+
suggestion:
|
|
89
|
+
'See documentation for setting up App Store Connect API credentials.',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
analyzer: this.name,
|
|
94
|
+
passed: true,
|
|
95
|
+
issues,
|
|
96
|
+
duration: Date.now() - startTime,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Get bundle ID from project
|
|
101
|
+
const bundleId = options?.bundleId ?? this.getBundleIdFromProject(project);
|
|
102
|
+
if (!bundleId) {
|
|
103
|
+
issues.push({
|
|
104
|
+
id: 'asc-no-bundle-id',
|
|
105
|
+
title: 'No bundle ID found',
|
|
106
|
+
description: 'Could not determine bundle ID from project to query App Store Connect.',
|
|
107
|
+
severity: 'warning',
|
|
108
|
+
category: 'metadata',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
analyzer: this.name,
|
|
113
|
+
passed: true,
|
|
114
|
+
issues,
|
|
115
|
+
duration: Date.now() - startTime,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const appData = await getAppWithInfo(bundleId);
|
|
121
|
+
|
|
122
|
+
// Validate app info localizations
|
|
123
|
+
for (const localization of appData.localizations) {
|
|
124
|
+
const locIssues = this.validateLocalization(localization, appData.app.attributes.primaryLocale);
|
|
125
|
+
issues.push(...locIssues);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check for missing primary locale
|
|
129
|
+
const primaryLocale = appData.app.attributes.primaryLocale;
|
|
130
|
+
const hasPrimaryLocale = appData.localizations.some(
|
|
131
|
+
(loc) => loc.attributes.locale === primaryLocale
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (!hasPrimaryLocale && appData.localizations.length > 0) {
|
|
135
|
+
issues.push({
|
|
136
|
+
id: 'asc-missing-primary-locale',
|
|
137
|
+
title: 'Missing primary locale metadata',
|
|
138
|
+
description: `App info localization for primary locale "${primaryLocale}" not found.`,
|
|
139
|
+
severity: 'warning',
|
|
140
|
+
category: 'metadata',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (isASCError(error)) {
|
|
145
|
+
issues.push({
|
|
146
|
+
id: error.code,
|
|
147
|
+
title: error.name,
|
|
148
|
+
description: error.message,
|
|
149
|
+
severity: 'error',
|
|
150
|
+
category: 'metadata',
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
issues.push({
|
|
154
|
+
id: 'asc-api-error',
|
|
155
|
+
title: 'App Store Connect API Error',
|
|
156
|
+
description: error instanceof Error ? error.message : String(error),
|
|
157
|
+
severity: 'error',
|
|
158
|
+
category: 'metadata',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
analyzer: this.name,
|
|
165
|
+
passed: issues.filter((i) => i.severity === 'error').length === 0,
|
|
166
|
+
issues,
|
|
167
|
+
duration: Date.now() - startTime,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validate a single localization
|
|
173
|
+
*/
|
|
174
|
+
private validateLocalization(localization: AppInfoLocalization, primaryLocale: string): Issue[] {
|
|
175
|
+
const issues: Issue[] = [];
|
|
176
|
+
const locale = localization.attributes.locale;
|
|
177
|
+
const isPrimary = locale === primaryLocale;
|
|
178
|
+
|
|
179
|
+
// Validate app name
|
|
180
|
+
const name = localization.attributes.name;
|
|
181
|
+
if (name) {
|
|
182
|
+
if (name.length > LIMITS.appName) {
|
|
183
|
+
issues.push({
|
|
184
|
+
id: 'asc-name-too-long',
|
|
185
|
+
title: `App name too long (${locale})`,
|
|
186
|
+
description: `App name is ${name.length} characters, maximum is ${LIMITS.appName}.`,
|
|
187
|
+
severity: 'error',
|
|
188
|
+
category: 'metadata',
|
|
189
|
+
suggestion: `Shorten the app name to ${LIMITS.appName} characters or less.`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isPlaceholder(name)) {
|
|
194
|
+
issues.push({
|
|
195
|
+
id: 'asc-name-placeholder',
|
|
196
|
+
title: `Placeholder app name detected (${locale})`,
|
|
197
|
+
description: `App name "${name}" appears to be placeholder text.`,
|
|
198
|
+
severity: 'error',
|
|
199
|
+
category: 'metadata',
|
|
200
|
+
guideline: 'Guideline 2.3.7 - Accurate Metadata',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
} else if (isPrimary) {
|
|
204
|
+
issues.push({
|
|
205
|
+
id: 'asc-missing-name',
|
|
206
|
+
title: 'Missing app name',
|
|
207
|
+
description: `App name is not set for primary locale "${locale}".`,
|
|
208
|
+
severity: 'error',
|
|
209
|
+
category: 'metadata',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate subtitle
|
|
214
|
+
const subtitle = localization.attributes.subtitle;
|
|
215
|
+
if (subtitle) {
|
|
216
|
+
if (subtitle.length > LIMITS.subtitle) {
|
|
217
|
+
issues.push({
|
|
218
|
+
id: 'asc-subtitle-too-long',
|
|
219
|
+
title: `Subtitle too long (${locale})`,
|
|
220
|
+
description: `Subtitle is ${subtitle.length} characters, maximum is ${LIMITS.subtitle}.`,
|
|
221
|
+
severity: 'error',
|
|
222
|
+
category: 'metadata',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (isPlaceholder(subtitle)) {
|
|
227
|
+
issues.push({
|
|
228
|
+
id: 'asc-subtitle-placeholder',
|
|
229
|
+
title: `Placeholder subtitle detected (${locale})`,
|
|
230
|
+
description: `Subtitle "${subtitle}" appears to be placeholder text.`,
|
|
231
|
+
severity: 'error',
|
|
232
|
+
category: 'metadata',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Validate privacy policy URL
|
|
238
|
+
const privacyUrl = localization.attributes.privacyPolicyUrl;
|
|
239
|
+
if (!privacyUrl && isPrimary) {
|
|
240
|
+
issues.push({
|
|
241
|
+
id: 'asc-missing-privacy-policy',
|
|
242
|
+
title: 'Missing privacy policy URL',
|
|
243
|
+
description: 'Privacy policy URL is required for App Store submission.',
|
|
244
|
+
severity: 'error',
|
|
245
|
+
category: 'metadata',
|
|
246
|
+
guideline: 'Guideline 5.1.1 - Data Collection and Storage',
|
|
247
|
+
suggestion: 'Add a privacy policy URL to your app metadata.',
|
|
248
|
+
});
|
|
249
|
+
} else if (privacyUrl && !isValidUrl(privacyUrl)) {
|
|
250
|
+
issues.push({
|
|
251
|
+
id: 'asc-invalid-privacy-url',
|
|
252
|
+
title: `Invalid privacy policy URL (${locale})`,
|
|
253
|
+
description: `Privacy policy URL "${privacyUrl}" is not a valid URL.`,
|
|
254
|
+
severity: 'error',
|
|
255
|
+
category: 'metadata',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return issues;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Standalone metadata validation by bundle ID
|
|
264
|
+
*/
|
|
265
|
+
async validateByBundleId(bundleId: string): Promise<AnalysisResult> {
|
|
266
|
+
const startTime = Date.now();
|
|
267
|
+
const issues: Issue[] = [];
|
|
268
|
+
|
|
269
|
+
if (!hasCredentials()) {
|
|
270
|
+
return {
|
|
271
|
+
analyzer: this.name,
|
|
272
|
+
passed: false,
|
|
273
|
+
issues: [
|
|
274
|
+
{
|
|
275
|
+
id: 'asc-credentials-not-configured',
|
|
276
|
+
title: 'App Store Connect credentials not configured',
|
|
277
|
+
description:
|
|
278
|
+
'Set ASC_KEY_ID, ASC_ISSUER_ID, and ASC_PRIVATE_KEY_PATH environment variables.',
|
|
279
|
+
severity: 'error',
|
|
280
|
+
category: 'metadata',
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
duration: Date.now() - startTime,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const appData = await getAppWithInfo(bundleId);
|
|
289
|
+
|
|
290
|
+
for (const localization of appData.localizations) {
|
|
291
|
+
const locIssues = this.validateLocalization(
|
|
292
|
+
localization,
|
|
293
|
+
appData.app.attributes.primaryLocale
|
|
294
|
+
);
|
|
295
|
+
issues.push(...locIssues);
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (isASCError(error)) {
|
|
299
|
+
issues.push({
|
|
300
|
+
id: error.code,
|
|
301
|
+
title: error.name,
|
|
302
|
+
description: error.message,
|
|
303
|
+
severity: 'error',
|
|
304
|
+
category: 'metadata',
|
|
305
|
+
});
|
|
306
|
+
} else {
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
analyzer: this.name,
|
|
313
|
+
passed: issues.filter((i) => i.severity === 'error').length === 0,
|
|
314
|
+
issues,
|
|
315
|
+
duration: Date.now() - startTime,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get bundle ID from project targets
|
|
321
|
+
*/
|
|
322
|
+
private getBundleIdFromProject(project: XcodeProject): string | undefined {
|
|
323
|
+
const appTarget = project.targets.find((t) => t.type === 'application');
|
|
324
|
+
return appTarget?.bundleIdentifier;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Store Connect Screenshot Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Validates screenshots in ASC including required device sizes,
|
|
5
|
+
* screenshot counts, processing status, and localized presence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Analyzer,
|
|
10
|
+
AnalysisResult,
|
|
11
|
+
AnalyzerOptions,
|
|
12
|
+
Issue,
|
|
13
|
+
XcodeProject,
|
|
14
|
+
} from '../types/index.js';
|
|
15
|
+
import {
|
|
16
|
+
hasCredentials,
|
|
17
|
+
getAppByBundleId,
|
|
18
|
+
getEditableVersion,
|
|
19
|
+
getVersionLocalizations,
|
|
20
|
+
getScreenshotSetsWithScreenshots,
|
|
21
|
+
REQUIRED_IPHONE_DISPLAY_TYPES,
|
|
22
|
+
REQUIRED_IPAD_DISPLAY_TYPES,
|
|
23
|
+
getDisplayTypeDescription,
|
|
24
|
+
validateScreenshotSet,
|
|
25
|
+
isASCError,
|
|
26
|
+
type ScreenshotDisplayType,
|
|
27
|
+
type AppStoreVersionLocalization,
|
|
28
|
+
} from '../asc/index.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Screenshot requirements
|
|
32
|
+
*/
|
|
33
|
+
const MAX_SCREENSHOTS = 10;
|
|
34
|
+
|
|
35
|
+
export class ASCScreenshotAnalyzer implements Analyzer {
|
|
36
|
+
name = 'ASC Screenshot Analyzer';
|
|
37
|
+
description = 'Validates screenshots in App Store Connect';
|
|
38
|
+
|
|
39
|
+
async analyze(project: XcodeProject, options?: AnalyzerOptions): Promise<AnalysisResult> {
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
const issues: Issue[] = [];
|
|
42
|
+
|
|
43
|
+
if (!hasCredentials()) {
|
|
44
|
+
issues.push({
|
|
45
|
+
id: 'asc-credentials-not-configured',
|
|
46
|
+
title: 'App Store Connect credentials not configured',
|
|
47
|
+
description:
|
|
48
|
+
'ASC credentials are not configured. Set environment variables to enable screenshot validation.',
|
|
49
|
+
severity: 'info',
|
|
50
|
+
category: 'screenshots',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
analyzer: this.name,
|
|
55
|
+
passed: true,
|
|
56
|
+
issues,
|
|
57
|
+
duration: Date.now() - startTime,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const bundleId = options?.bundleId ?? this.getBundleIdFromProject(project);
|
|
62
|
+
if (!bundleId) {
|
|
63
|
+
issues.push({
|
|
64
|
+
id: 'asc-no-bundle-id',
|
|
65
|
+
title: 'No bundle ID found',
|
|
66
|
+
description: 'Could not determine bundle ID from project.',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
category: 'screenshots',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
analyzer: this.name,
|
|
73
|
+
passed: true,
|
|
74
|
+
issues,
|
|
75
|
+
duration: Date.now() - startTime,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const screenshotIssues = await this.validateScreenshotsForBundleId(bundleId);
|
|
81
|
+
issues.push(...screenshotIssues);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (isASCError(error)) {
|
|
84
|
+
issues.push({
|
|
85
|
+
id: error.code,
|
|
86
|
+
title: error.name,
|
|
87
|
+
description: error.message,
|
|
88
|
+
severity: 'error',
|
|
89
|
+
category: 'screenshots',
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
issues.push({
|
|
93
|
+
id: 'asc-api-error',
|
|
94
|
+
title: 'App Store Connect API Error',
|
|
95
|
+
description: error instanceof Error ? error.message : String(error),
|
|
96
|
+
severity: 'error',
|
|
97
|
+
category: 'screenshots',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
analyzer: this.name,
|
|
104
|
+
passed: issues.filter((i) => i.severity === 'error').length === 0,
|
|
105
|
+
issues,
|
|
106
|
+
duration: Date.now() - startTime,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate screenshots for a bundle ID
|
|
112
|
+
*/
|
|
113
|
+
async validateByBundleId(bundleId: string): Promise<AnalysisResult> {
|
|
114
|
+
const startTime = Date.now();
|
|
115
|
+
const issues: Issue[] = [];
|
|
116
|
+
|
|
117
|
+
if (!hasCredentials()) {
|
|
118
|
+
return {
|
|
119
|
+
analyzer: this.name,
|
|
120
|
+
passed: false,
|
|
121
|
+
issues: [
|
|
122
|
+
{
|
|
123
|
+
id: 'asc-credentials-not-configured',
|
|
124
|
+
title: 'App Store Connect credentials not configured',
|
|
125
|
+
description: 'Set ASC environment variables to enable validation.',
|
|
126
|
+
severity: 'error',
|
|
127
|
+
category: 'screenshots',
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
duration: Date.now() - startTime,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const screenshotIssues = await this.validateScreenshotsForBundleId(bundleId);
|
|
136
|
+
issues.push(...screenshotIssues);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isASCError(error)) {
|
|
139
|
+
issues.push({
|
|
140
|
+
id: error.code,
|
|
141
|
+
title: error.name,
|
|
142
|
+
description: error.message,
|
|
143
|
+
severity: 'error',
|
|
144
|
+
category: 'screenshots',
|
|
145
|
+
});
|
|
146
|
+
} else {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
analyzer: this.name,
|
|
153
|
+
passed: issues.filter((i) => i.severity === 'error').length === 0,
|
|
154
|
+
issues,
|
|
155
|
+
duration: Date.now() - startTime,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Internal screenshot validation
|
|
161
|
+
*/
|
|
162
|
+
private async validateScreenshotsForBundleId(bundleId: string): Promise<Issue[]> {
|
|
163
|
+
const issues: Issue[] = [];
|
|
164
|
+
|
|
165
|
+
const app = await getAppByBundleId(bundleId);
|
|
166
|
+
const version = await getEditableVersion(app.id);
|
|
167
|
+
|
|
168
|
+
if (!version) {
|
|
169
|
+
issues.push({
|
|
170
|
+
id: 'asc-no-editable-version',
|
|
171
|
+
title: 'No editable version found',
|
|
172
|
+
description:
|
|
173
|
+
'No app version in editable state (PREPARE_FOR_SUBMISSION, etc.) found in App Store Connect.',
|
|
174
|
+
severity: 'info',
|
|
175
|
+
category: 'screenshots',
|
|
176
|
+
});
|
|
177
|
+
return issues;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const localizations = await getVersionLocalizations(version.id);
|
|
181
|
+
|
|
182
|
+
if (localizations.length === 0) {
|
|
183
|
+
issues.push({
|
|
184
|
+
id: 'asc-no-localizations',
|
|
185
|
+
title: 'No version localizations found',
|
|
186
|
+
description: 'No localizations configured for the current app version.',
|
|
187
|
+
severity: 'warning',
|
|
188
|
+
category: 'screenshots',
|
|
189
|
+
});
|
|
190
|
+
return issues;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check each localization
|
|
194
|
+
for (const localization of localizations) {
|
|
195
|
+
const locIssues = await this.validateLocalizationScreenshots(localization);
|
|
196
|
+
issues.push(...locIssues);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return issues;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validate screenshots for a single localization
|
|
204
|
+
*/
|
|
205
|
+
private async validateLocalizationScreenshots(
|
|
206
|
+
localization: AppStoreVersionLocalization
|
|
207
|
+
): Promise<Issue[]> {
|
|
208
|
+
const issues: Issue[] = [];
|
|
209
|
+
const locale = localization.attributes.locale;
|
|
210
|
+
|
|
211
|
+
const screenshotSets = await getScreenshotSetsWithScreenshots(localization.id);
|
|
212
|
+
|
|
213
|
+
// Track which required types are present
|
|
214
|
+
const presentTypes = new Set<ScreenshotDisplayType>(
|
|
215
|
+
screenshotSets.map((s) => s.set.attributes.screenshotDisplayType)
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Check required iPhone sizes
|
|
219
|
+
const missingIPhoneTypes = REQUIRED_IPHONE_DISPLAY_TYPES.filter(
|
|
220
|
+
(type) => !presentTypes.has(type)
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (missingIPhoneTypes.length > 0) {
|
|
224
|
+
// At least one iPhone size is required
|
|
225
|
+
const hasAnyIPhoneScreenshots = REQUIRED_IPHONE_DISPLAY_TYPES.some((type) =>
|
|
226
|
+
presentTypes.has(type)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (!hasAnyIPhoneScreenshots) {
|
|
230
|
+
issues.push({
|
|
231
|
+
id: 'asc-missing-iphone-screenshots',
|
|
232
|
+
title: `Missing iPhone screenshots (${locale})`,
|
|
233
|
+
description: `No iPhone screenshots found. At least one of these sizes is required: ${missingIPhoneTypes.map(getDisplayTypeDescription).join(', ')}`,
|
|
234
|
+
severity: 'error',
|
|
235
|
+
category: 'screenshots',
|
|
236
|
+
suggestion: 'Upload screenshots for at least iPhone 6.5" or 5.5" display.',
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
issues.push({
|
|
240
|
+
id: 'asc-incomplete-iphone-screenshots',
|
|
241
|
+
title: `Incomplete iPhone screenshot sizes (${locale})`,
|
|
242
|
+
description: `Missing screenshots for: ${missingIPhoneTypes.map(getDisplayTypeDescription).join(', ')}`,
|
|
243
|
+
severity: 'warning',
|
|
244
|
+
category: 'screenshots',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check iPad sizes if app supports iPad
|
|
250
|
+
const missingIPadTypes = REQUIRED_IPAD_DISPLAY_TYPES.filter(
|
|
251
|
+
(type) => !presentTypes.has(type)
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if (missingIPadTypes.length === REQUIRED_IPAD_DISPLAY_TYPES.length) {
|
|
255
|
+
// No iPad screenshots - this might be intentional if app is iPhone-only
|
|
256
|
+
issues.push({
|
|
257
|
+
id: 'asc-no-ipad-screenshots',
|
|
258
|
+
title: `No iPad screenshots (${locale})`,
|
|
259
|
+
description:
|
|
260
|
+
'No iPad screenshots found. If your app supports iPad, screenshots are required for iPad Pro 12.9".',
|
|
261
|
+
severity: 'info',
|
|
262
|
+
category: 'screenshots',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Validate each screenshot set
|
|
267
|
+
for (const { set, screenshots } of screenshotSets) {
|
|
268
|
+
const validation = validateScreenshotSet(set, screenshots);
|
|
269
|
+
|
|
270
|
+
// Check count
|
|
271
|
+
if (screenshots.length === 0) {
|
|
272
|
+
issues.push({
|
|
273
|
+
id: 'asc-empty-screenshot-set',
|
|
274
|
+
title: `Empty screenshot set (${locale})`,
|
|
275
|
+
description: `Screenshot set for ${getDisplayTypeDescription(set.attributes.screenshotDisplayType)} has no screenshots.`,
|
|
276
|
+
severity: 'warning',
|
|
277
|
+
category: 'screenshots',
|
|
278
|
+
});
|
|
279
|
+
} else if (screenshots.length > MAX_SCREENSHOTS) {
|
|
280
|
+
issues.push({
|
|
281
|
+
id: 'asc-too-many-screenshots',
|
|
282
|
+
title: `Too many screenshots (${locale})`,
|
|
283
|
+
description: `Screenshot set for ${getDisplayTypeDescription(set.attributes.screenshotDisplayType)} has ${screenshots.length} screenshots (max ${MAX_SCREENSHOTS}).`,
|
|
284
|
+
severity: 'error',
|
|
285
|
+
category: 'screenshots',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Check processing errors
|
|
290
|
+
if (validation.hasProcessingErrors) {
|
|
291
|
+
for (const issue of validation.issues) {
|
|
292
|
+
issues.push({
|
|
293
|
+
id: 'asc-screenshot-processing-error',
|
|
294
|
+
title: `Screenshot processing error (${locale})`,
|
|
295
|
+
description: issue,
|
|
296
|
+
severity: 'error',
|
|
297
|
+
category: 'screenshots',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return issues;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private getBundleIdFromProject(project: XcodeProject): string | undefined {
|
|
307
|
+
const appTarget = project.targets.find((t) => t.type === 'application');
|
|
308
|
+
return appTarget?.bundleIdentifier;
|
|
309
|
+
}
|
|
310
|
+
}
|