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,272 @@
|
|
|
1
|
+
import { ScanComparator } from '../../src/history/comparator.js';
|
|
2
|
+
import type { ScanRecord } from '../../src/history/types.js';
|
|
3
|
+
import type { AnalysisReport, Issue } from '../../src/types/index.js';
|
|
4
|
+
|
|
5
|
+
describe('ScanComparator', () => {
|
|
6
|
+
let comparator: ScanComparator;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
jest.clearAllMocks();
|
|
10
|
+
comparator = new ScanComparator();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const createIssue = (overrides: Partial<Issue> = {}): Issue => ({
|
|
14
|
+
id: 'test-issue',
|
|
15
|
+
title: 'Test Issue',
|
|
16
|
+
description: 'A test issue',
|
|
17
|
+
severity: 'warning',
|
|
18
|
+
category: 'code',
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const createScanRecord = (overrides: Partial<ScanRecord> = {}): ScanRecord => ({
|
|
23
|
+
id: 'scan-1',
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
projectPath: '/project',
|
|
26
|
+
score: 80,
|
|
27
|
+
report: {
|
|
28
|
+
projectPath: '/project',
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
results: [],
|
|
31
|
+
summary: { totalIssues: 0, errors: 0, warnings: 0, info: 0, passed: true, duration: 50 },
|
|
32
|
+
},
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('fingerprint()', () => {
|
|
37
|
+
it('should create stable fingerprint from issue', () => {
|
|
38
|
+
const issue = createIssue({
|
|
39
|
+
id: 'plist-missing-key',
|
|
40
|
+
category: 'info-plist',
|
|
41
|
+
filePath: '/project/src/Info.plist',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const fp1 = comparator.fingerprint(issue, '/project');
|
|
45
|
+
const fp2 = comparator.fingerprint(issue, '/project');
|
|
46
|
+
expect(fp1).toBe(fp2);
|
|
47
|
+
expect(fp1).toBe('plist-missing-key::info-plist::src/Info.plist');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should use relative path', () => {
|
|
51
|
+
const issue = createIssue({
|
|
52
|
+
id: 'code-issue',
|
|
53
|
+
category: 'code',
|
|
54
|
+
filePath: '/project/Sources/App/ViewController.swift',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const fp = comparator.fingerprint(issue, '/project');
|
|
58
|
+
expect(fp).toBe('code-issue::code::Sources/App/ViewController.swift');
|
|
59
|
+
expect(fp).not.toContain('/project/');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should handle missing filePath', () => {
|
|
63
|
+
const issue = createIssue({
|
|
64
|
+
id: 'general-issue',
|
|
65
|
+
category: 'privacy',
|
|
66
|
+
filePath: undefined,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const fp = comparator.fingerprint(issue, '/project');
|
|
70
|
+
expect(fp).toBe('general-issue::privacy::');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('compare()', () => {
|
|
75
|
+
it('should detect new issues', () => {
|
|
76
|
+
const previous = createScanRecord({
|
|
77
|
+
id: 'prev',
|
|
78
|
+
score: 90,
|
|
79
|
+
report: {
|
|
80
|
+
projectPath: '/project',
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
results: [],
|
|
83
|
+
summary: { totalIssues: 0, errors: 0, warnings: 0, info: 0, passed: true, duration: 50 },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const current = createScanRecord({
|
|
88
|
+
id: 'curr',
|
|
89
|
+
score: 70,
|
|
90
|
+
report: {
|
|
91
|
+
projectPath: '/project',
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
results: [
|
|
94
|
+
{
|
|
95
|
+
analyzer: 'Code Scanner',
|
|
96
|
+
passed: false,
|
|
97
|
+
issues: [
|
|
98
|
+
createIssue({ id: 'new-issue', category: 'code', filePath: '/project/src/file.swift' }),
|
|
99
|
+
],
|
|
100
|
+
duration: 30,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
summary: { totalIssues: 1, errors: 0, warnings: 1, info: 0, passed: true, duration: 50 },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = comparator.compare(previous, current);
|
|
108
|
+
expect(result.newIssues).toHaveLength(1);
|
|
109
|
+
expect(result.resolvedIssues).toHaveLength(0);
|
|
110
|
+
expect(result.ongoingIssues).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should detect resolved issues', () => {
|
|
114
|
+
const previous = createScanRecord({
|
|
115
|
+
id: 'prev',
|
|
116
|
+
score: 70,
|
|
117
|
+
report: {
|
|
118
|
+
projectPath: '/project',
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
results: [
|
|
121
|
+
{
|
|
122
|
+
analyzer: 'Code Scanner',
|
|
123
|
+
passed: false,
|
|
124
|
+
issues: [
|
|
125
|
+
createIssue({ id: 'old-issue', category: 'code', filePath: '/project/src/file.swift' }),
|
|
126
|
+
],
|
|
127
|
+
duration: 30,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
summary: { totalIssues: 1, errors: 0, warnings: 1, info: 0, passed: true, duration: 50 },
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const current = createScanRecord({
|
|
135
|
+
id: 'curr',
|
|
136
|
+
score: 95,
|
|
137
|
+
report: {
|
|
138
|
+
projectPath: '/project',
|
|
139
|
+
timestamp: new Date().toISOString(),
|
|
140
|
+
results: [],
|
|
141
|
+
summary: { totalIssues: 0, errors: 0, warnings: 0, info: 0, passed: true, duration: 50 },
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = comparator.compare(previous, current);
|
|
146
|
+
expect(result.newIssues).toHaveLength(0);
|
|
147
|
+
expect(result.resolvedIssues).toHaveLength(1);
|
|
148
|
+
expect(result.ongoingIssues).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should detect ongoing issues', () => {
|
|
152
|
+
const sharedIssue = createIssue({
|
|
153
|
+
id: 'ongoing-issue',
|
|
154
|
+
category: 'security',
|
|
155
|
+
filePath: '/project/src/Crypto.swift',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const previous = createScanRecord({
|
|
159
|
+
id: 'prev',
|
|
160
|
+
score: 75,
|
|
161
|
+
report: {
|
|
162
|
+
projectPath: '/project',
|
|
163
|
+
timestamp: new Date().toISOString(),
|
|
164
|
+
results: [
|
|
165
|
+
{ analyzer: 'Security', passed: false, issues: [sharedIssue], duration: 30 },
|
|
166
|
+
],
|
|
167
|
+
summary: { totalIssues: 1, errors: 0, warnings: 1, info: 0, passed: true, duration: 50 },
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const current = createScanRecord({
|
|
172
|
+
id: 'curr',
|
|
173
|
+
score: 75,
|
|
174
|
+
report: {
|
|
175
|
+
projectPath: '/project',
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
results: [
|
|
178
|
+
{ analyzer: 'Security', passed: false, issues: [sharedIssue], duration: 25 },
|
|
179
|
+
],
|
|
180
|
+
summary: { totalIssues: 1, errors: 0, warnings: 1, info: 0, passed: true, duration: 50 },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const result = comparator.compare(previous, current);
|
|
185
|
+
expect(result.ongoingIssues).toHaveLength(1);
|
|
186
|
+
expect(result.newIssues).toHaveLength(0);
|
|
187
|
+
expect(result.resolvedIssues).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should calculate score delta', () => {
|
|
191
|
+
const previous = createScanRecord({ id: 'prev', score: 60 });
|
|
192
|
+
const current = createScanRecord({ id: 'curr', score: 85 });
|
|
193
|
+
|
|
194
|
+
const result = comparator.compare(previous, current);
|
|
195
|
+
expect(result.scoreDelta).toBe(25);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should set trend to improving when score increases significantly', () => {
|
|
199
|
+
const previous = createScanRecord({ id: 'prev', score: 60 });
|
|
200
|
+
const current = createScanRecord({ id: 'curr', score: 80 });
|
|
201
|
+
|
|
202
|
+
const result = comparator.compare(previous, current);
|
|
203
|
+
expect(result.trend).toBe('improving');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should set trend to declining when score decreases significantly', () => {
|
|
207
|
+
const previous = createScanRecord({ id: 'prev', score: 90 });
|
|
208
|
+
const current = createScanRecord({ id: 'curr', score: 60 });
|
|
209
|
+
|
|
210
|
+
const result = comparator.compare(previous, current);
|
|
211
|
+
expect(result.trend).toBe('declining');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should set trend to stable for small changes', () => {
|
|
215
|
+
const previous = createScanRecord({ id: 'prev', score: 80 });
|
|
216
|
+
const current = createScanRecord({ id: 'curr', score: 81 });
|
|
217
|
+
|
|
218
|
+
const result = comparator.compare(previous, current);
|
|
219
|
+
expect(result.trend).toBe('stable');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('analyzeTrend()', () => {
|
|
224
|
+
it('should compute trend from multiple scans', () => {
|
|
225
|
+
const scans: ScanRecord[] = [
|
|
226
|
+
createScanRecord({ id: 'scan-1', timestamp: '2024-01-01T00:00:00Z', score: 50 }),
|
|
227
|
+
createScanRecord({ id: 'scan-2', timestamp: '2024-01-02T00:00:00Z', score: 60 }),
|
|
228
|
+
createScanRecord({ id: 'scan-3', timestamp: '2024-01-03T00:00:00Z', score: 70 }),
|
|
229
|
+
createScanRecord({ id: 'scan-4', timestamp: '2024-01-04T00:00:00Z', score: 80 }),
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const trend = comparator.analyzeTrend(scans);
|
|
233
|
+
|
|
234
|
+
expect(trend.scans).toHaveLength(4);
|
|
235
|
+
expect(trend.overallTrend).toBe('improving');
|
|
236
|
+
expect(trend.averageScore).toBe(65); // Math.round((50+60+70+80)/4) = 65
|
|
237
|
+
expect(trend.bestScore).toBe(80);
|
|
238
|
+
expect(trend.worstScore).toBe(50);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should detect declining trend', () => {
|
|
242
|
+
const scans: ScanRecord[] = [
|
|
243
|
+
createScanRecord({ id: 'scan-1', timestamp: '2024-01-01T00:00:00Z', score: 90 }),
|
|
244
|
+
createScanRecord({ id: 'scan-2', timestamp: '2024-01-02T00:00:00Z', score: 80 }),
|
|
245
|
+
createScanRecord({ id: 'scan-3', timestamp: '2024-01-03T00:00:00Z', score: 70 }),
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const trend = comparator.analyzeTrend(scans);
|
|
249
|
+
expect(trend.overallTrend).toBe('declining');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should detect stable trend', () => {
|
|
253
|
+
const scans: ScanRecord[] = [
|
|
254
|
+
createScanRecord({ id: 'scan-1', timestamp: '2024-01-01T00:00:00Z', score: 80 }),
|
|
255
|
+
createScanRecord({ id: 'scan-2', timestamp: '2024-01-02T00:00:00Z', score: 81 }),
|
|
256
|
+
createScanRecord({ id: 'scan-3', timestamp: '2024-01-03T00:00:00Z', score: 82 }),
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
const trend = comparator.analyzeTrend(scans);
|
|
260
|
+
expect(trend.overallTrend).toBe('stable');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should handle empty scans array', () => {
|
|
264
|
+
const trend = comparator.analyzeTrend([]);
|
|
265
|
+
expect(trend.scans).toHaveLength(0);
|
|
266
|
+
expect(trend.overallTrend).toBe('stable');
|
|
267
|
+
expect(trend.averageScore).toBe(0);
|
|
268
|
+
expect(trend.bestScore).toBe(0);
|
|
269
|
+
expect(trend.worstScore).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { HistoryStore } from '../../src/history/store.js';
|
|
5
|
+
import type { AnalysisReport } from '../../src/types/index.js';
|
|
6
|
+
|
|
7
|
+
jest.mock('child_process', () => ({
|
|
8
|
+
execSync: jest.fn().mockReturnValue('abc123\n'),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('HistoryStore', () => {
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
let store: HistoryStore;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'history-store-test-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
store = new HistoryStore(tempDir);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const createMockReport = (): AnalysisReport => ({
|
|
29
|
+
projectPath: tempDir,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
results: [],
|
|
32
|
+
summary: { totalIssues: 0, errors: 0, warnings: 0, info: 0, passed: true, duration: 50 },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('saveScan()', () => {
|
|
36
|
+
it('should save and return a scan record with an ID', async () => {
|
|
37
|
+
const mockReport = createMockReport();
|
|
38
|
+
const record = await store.saveScan(mockReport, 95);
|
|
39
|
+
|
|
40
|
+
expect(record).toBeDefined();
|
|
41
|
+
expect(record.id).toBeDefined();
|
|
42
|
+
expect(typeof record.id).toBe('string');
|
|
43
|
+
expect(record.id.length).toBeGreaterThan(0);
|
|
44
|
+
expect(record.projectPath).toBe(tempDir);
|
|
45
|
+
expect(record.score).toBe(95);
|
|
46
|
+
expect(record.report).toEqual(mockReport);
|
|
47
|
+
expect(record.gitCommit).toBe('abc123');
|
|
48
|
+
expect(record.gitBranch).toBe('abc123');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('getLatestScan()', () => {
|
|
53
|
+
it('should return the most recently saved scan', async () => {
|
|
54
|
+
// Use a fresh subdirectory so we start with clean state
|
|
55
|
+
const subDir = path.join(tempDir, 'latest-test');
|
|
56
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
57
|
+
const freshStore = new HistoryStore(subDir);
|
|
58
|
+
|
|
59
|
+
const report1 = createMockReport();
|
|
60
|
+
report1.projectPath = subDir;
|
|
61
|
+
await freshStore.saveScan(report1, 80);
|
|
62
|
+
|
|
63
|
+
const report2 = createMockReport();
|
|
64
|
+
report2.projectPath = subDir;
|
|
65
|
+
const secondRecord = await freshStore.saveScan(report2, 90);
|
|
66
|
+
|
|
67
|
+
const latest = await freshStore.getLatestScan();
|
|
68
|
+
expect(latest).not.toBeNull();
|
|
69
|
+
expect(latest!.id).toBe(secondRecord.id);
|
|
70
|
+
expect(latest!.score).toBe(90);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return null when no scans exist', async () => {
|
|
74
|
+
const emptyDir = path.join(tempDir, 'empty-latest');
|
|
75
|
+
await fs.mkdir(emptyDir, { recursive: true });
|
|
76
|
+
const emptyStore = new HistoryStore(emptyDir);
|
|
77
|
+
|
|
78
|
+
const latest = await emptyStore.getLatestScan();
|
|
79
|
+
expect(latest).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('getScan()', () => {
|
|
84
|
+
it('should return a specific scan by ID', async () => {
|
|
85
|
+
const subDir = path.join(tempDir, 'get-scan-test');
|
|
86
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
87
|
+
const freshStore = new HistoryStore(subDir);
|
|
88
|
+
|
|
89
|
+
const report = createMockReport();
|
|
90
|
+
report.projectPath = subDir;
|
|
91
|
+
const saved = await freshStore.saveScan(report, 85);
|
|
92
|
+
|
|
93
|
+
const retrieved = await freshStore.getScan(saved.id);
|
|
94
|
+
expect(retrieved).not.toBeNull();
|
|
95
|
+
expect(retrieved!.id).toBe(saved.id);
|
|
96
|
+
expect(retrieved!.score).toBe(85);
|
|
97
|
+
expect(retrieved!.report).toEqual(report);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should return null for unknown ID', async () => {
|
|
101
|
+
const subDir = path.join(tempDir, 'get-scan-unknown');
|
|
102
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
103
|
+
const freshStore = new HistoryStore(subDir);
|
|
104
|
+
|
|
105
|
+
const result = await freshStore.getScan('non-existent-id');
|
|
106
|
+
expect(result).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('listScans()', () => {
|
|
111
|
+
it('should return scans in reverse chronological order (newest first)', async () => {
|
|
112
|
+
const subDir = path.join(tempDir, 'list-scans-order');
|
|
113
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
114
|
+
const freshStore = new HistoryStore(subDir);
|
|
115
|
+
|
|
116
|
+
const report1 = createMockReport();
|
|
117
|
+
report1.projectPath = subDir;
|
|
118
|
+
const first = await freshStore.saveScan(report1, 70);
|
|
119
|
+
|
|
120
|
+
const report2 = createMockReport();
|
|
121
|
+
report2.projectPath = subDir;
|
|
122
|
+
const second = await freshStore.saveScan(report2, 80);
|
|
123
|
+
|
|
124
|
+
const report3 = createMockReport();
|
|
125
|
+
report3.projectPath = subDir;
|
|
126
|
+
const third = await freshStore.saveScan(report3, 90);
|
|
127
|
+
|
|
128
|
+
const scans = await freshStore.listScans();
|
|
129
|
+
expect(scans).toHaveLength(3);
|
|
130
|
+
expect(scans[0]!.id).toBe(third.id);
|
|
131
|
+
expect(scans[1]!.id).toBe(second.id);
|
|
132
|
+
expect(scans[2]!.id).toBe(first.id);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should respect the limit parameter', async () => {
|
|
136
|
+
const subDir = path.join(tempDir, 'list-scans-limit');
|
|
137
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
138
|
+
const freshStore = new HistoryStore(subDir);
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < 5; i++) {
|
|
141
|
+
const report = createMockReport();
|
|
142
|
+
report.projectPath = subDir;
|
|
143
|
+
await freshStore.saveScan(report, 50 + i * 10);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const scans = await freshStore.listScans(2);
|
|
147
|
+
expect(scans).toHaveLength(2);
|
|
148
|
+
// Newest first, so the last saved (score 90) should be first
|
|
149
|
+
expect(scans[0]!.score).toBe(90);
|
|
150
|
+
expect(scans[1]!.score).toBe(80);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('pruneHistory()', () => {
|
|
155
|
+
it('should remove oldest scans and keep specified count', async () => {
|
|
156
|
+
const subDir = path.join(tempDir, 'prune-test');
|
|
157
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
158
|
+
const freshStore = new HistoryStore(subDir);
|
|
159
|
+
|
|
160
|
+
const ids: string[] = [];
|
|
161
|
+
for (let i = 0; i < 5; i++) {
|
|
162
|
+
const report = createMockReport();
|
|
163
|
+
report.projectPath = subDir;
|
|
164
|
+
const record = await freshStore.saveScan(report, 50 + i * 10);
|
|
165
|
+
ids.push(record.id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const removed = await freshStore.pruneHistory(2);
|
|
169
|
+
expect(removed).toBe(3);
|
|
170
|
+
|
|
171
|
+
const scans = await freshStore.listScans();
|
|
172
|
+
expect(scans).toHaveLength(2);
|
|
173
|
+
// The two newest should remain (ids[3] and ids[4])
|
|
174
|
+
expect(scans[0]!.id).toBe(ids[4]);
|
|
175
|
+
expect(scans[1]!.id).toBe(ids[3]);
|
|
176
|
+
|
|
177
|
+
// Oldest should be gone
|
|
178
|
+
const oldScan = await freshStore.getScan(ids[0]!);
|
|
179
|
+
expect(oldScan).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should not remove anything if under limit', async () => {
|
|
183
|
+
const subDir = path.join(tempDir, 'prune-under-limit');
|
|
184
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
185
|
+
const freshStore = new HistoryStore(subDir);
|
|
186
|
+
|
|
187
|
+
for (let i = 0; i < 3; i++) {
|
|
188
|
+
const report = createMockReport();
|
|
189
|
+
report.projectPath = subDir;
|
|
190
|
+
await freshStore.saveScan(report, 60 + i * 10);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const removed = await freshStore.pruneHistory(5);
|
|
194
|
+
expect(removed).toBe(0);
|
|
195
|
+
|
|
196
|
+
const scans = await freshStore.listScans();
|
|
197
|
+
expect(scans).toHaveLength(3);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
const CLI_PATH = path.resolve(__dirname, '../../src/index.ts');
|
|
5
|
+
const TSX = 'npx tsx';
|
|
6
|
+
|
|
7
|
+
describe('CLI Integration', () => {
|
|
8
|
+
it('should print help with help command', () => {
|
|
9
|
+
const output = execSync(`${TSX} ${CLI_PATH} help`, {
|
|
10
|
+
encoding: 'utf-8',
|
|
11
|
+
timeout: 15000,
|
|
12
|
+
});
|
|
13
|
+
expect(output).toContain('ios-app-review');
|
|
14
|
+
expect(output).toContain('USAGE');
|
|
15
|
+
expect(output).toContain('scan');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should print help with --help flag', () => {
|
|
19
|
+
const output = execSync(`${TSX} ${CLI_PATH} --help`, {
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
timeout: 15000,
|
|
22
|
+
});
|
|
23
|
+
expect(output).toContain('USAGE');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should print version with version command', () => {
|
|
27
|
+
const output = execSync(`${TSX} ${CLI_PATH} version`, {
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
timeout: 15000,
|
|
30
|
+
});
|
|
31
|
+
expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should print version with --version flag', () => {
|
|
35
|
+
const output = execSync(`${TSX} ${CLI_PATH} --version`, {
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
timeout: 15000,
|
|
38
|
+
});
|
|
39
|
+
expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should exit with code 2 for unknown command', () => {
|
|
43
|
+
try {
|
|
44
|
+
execSync(`${TSX} ${CLI_PATH} unknown-command`, {
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
timeout: 15000,
|
|
47
|
+
});
|
|
48
|
+
fail('Should have thrown');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const error = err as { status: number; stderr: string };
|
|
51
|
+
expect(error.status).toBe(2);
|
|
52
|
+
expect(error.stderr).toContain('Unknown command');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should exit with code 2 when scan has no project path', () => {
|
|
57
|
+
try {
|
|
58
|
+
execSync(`${TSX} ${CLI_PATH} scan`, {
|
|
59
|
+
encoding: 'utf-8',
|
|
60
|
+
timeout: 15000,
|
|
61
|
+
});
|
|
62
|
+
fail('Should have thrown');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const error = err as { status: number; stderr: string };
|
|
65
|
+
expect(error.status).toBe(2);
|
|
66
|
+
expect(error.stderr).toContain('project path is required');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should exit with code 2 for invalid format', () => {
|
|
71
|
+
try {
|
|
72
|
+
execSync(`${TSX} ${CLI_PATH} scan /fake/path --format xml`, {
|
|
73
|
+
encoding: 'utf-8',
|
|
74
|
+
timeout: 15000,
|
|
75
|
+
});
|
|
76
|
+
fail('Should have thrown');
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const error = err as { status: number; stderr: string };
|
|
79
|
+
expect(error.status).toBe(2);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should exit with code 2 for invalid project path', () => {
|
|
84
|
+
try {
|
|
85
|
+
execSync(`${TSX} ${CLI_PATH} scan /nonexistent/path.xcodeproj`, {
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
timeout: 15000,
|
|
88
|
+
});
|
|
89
|
+
fail('Should have thrown');
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const error = err as { status: number };
|
|
92
|
+
expect(error.status).toBe(2);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { runAnalysis } from '../../src/analyzer.js';
|
|
5
|
+
import { createFormatter } from '../../src/reports/index.js';
|
|
6
|
+
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'e2e-test-'));
|
|
11
|
+
|
|
12
|
+
// Create a mock Xcode project structure
|
|
13
|
+
const xcodeproj = path.join(tempDir, 'TestApp.xcodeproj');
|
|
14
|
+
await fs.mkdir(xcodeproj, { recursive: true });
|
|
15
|
+
|
|
16
|
+
// Write a minimal pbxproj
|
|
17
|
+
await fs.writeFile(
|
|
18
|
+
path.join(xcodeproj, 'project.pbxproj'),
|
|
19
|
+
`// !$*UTF8*$!
|
|
20
|
+
{
|
|
21
|
+
archiveVersion = 1;
|
|
22
|
+
classes = {};
|
|
23
|
+
objectVersion = 56;
|
|
24
|
+
objects = {
|
|
25
|
+
ROOT = { isa = PBXProject; buildConfigurationList = CONFIGLIST; mainGroup = MAINGROUP; targets = (TARGET1); };
|
|
26
|
+
CONFIGLIST = { isa = XCConfigurationList; buildConfigurations = (CONFIG1); };
|
|
27
|
+
CONFIG1 = { isa = XCBuildConfiguration; name = Release; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "com.test.app"; IPHONEOS_DEPLOYMENT_TARGET = "15.0"; }; };
|
|
28
|
+
TARGET1 = { isa = PBXNativeTarget; name = TestApp; productType = "com.apple.product-type.application"; buildConfigurationList = CONFIGLIST; buildPhases = (); };
|
|
29
|
+
MAINGROUP = { isa = PBXGroup; children = (); sourceTree = "<group>"; };
|
|
30
|
+
};
|
|
31
|
+
rootObject = ROOT;
|
|
32
|
+
}
|
|
33
|
+
`
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Create source files with some detectable issues
|
|
37
|
+
await fs.writeFile(
|
|
38
|
+
path.join(tempDir, 'ViewController.swift'),
|
|
39
|
+
`import UIKit
|
|
40
|
+
|
|
41
|
+
class ViewController: UIViewController {
|
|
42
|
+
let apiKey = "sk-test1234567890abcdef"
|
|
43
|
+
|
|
44
|
+
override func viewDidLoad() {
|
|
45
|
+
super.viewDidLoad()
|
|
46
|
+
print("Debug: loaded")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Create Info.plist
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
path.join(tempDir, 'Info.plist'),
|
|
55
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
56
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
57
|
+
<plist version="1.0">
|
|
58
|
+
<dict>
|
|
59
|
+
<key>CFBundleIdentifier</key>
|
|
60
|
+
<string>com.test.app</string>
|
|
61
|
+
<key>CFBundleName</key>
|
|
62
|
+
<string>TestApp</string>
|
|
63
|
+
<key>CFBundleVersion</key>
|
|
64
|
+
<string>1</string>
|
|
65
|
+
<key>CFBundleShortVersionString</key>
|
|
66
|
+
<string>1.0.0</string>
|
|
67
|
+
</dict>
|
|
68
|
+
</plist>
|
|
69
|
+
`
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('E2E: Full Analysis Pipeline', () => {
|
|
78
|
+
it('should analyze a mock Xcode project end-to-end', async () => {
|
|
79
|
+
const report = await runAnalysis({
|
|
80
|
+
projectPath: path.join(tempDir, 'TestApp.xcodeproj'),
|
|
81
|
+
analyzers: ['code', 'security'],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(report).toBeDefined();
|
|
85
|
+
expect(report.projectPath).toContain('TestApp.xcodeproj');
|
|
86
|
+
expect(report.results.length).toBeGreaterThan(0);
|
|
87
|
+
expect(report.summary).toBeDefined();
|
|
88
|
+
expect(typeof report.summary.totalIssues).toBe('number');
|
|
89
|
+
expect(typeof report.summary.duration).toBe('number');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should format report as markdown', async () => {
|
|
93
|
+
const report = await runAnalysis({
|
|
94
|
+
projectPath: path.join(tempDir, 'TestApp.xcodeproj'),
|
|
95
|
+
analyzers: ['code'],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const formatter = createFormatter('markdown');
|
|
99
|
+
const output = formatter.format(report);
|
|
100
|
+
|
|
101
|
+
expect(output).toContain('#');
|
|
102
|
+
expect(typeof output).toBe('string');
|
|
103
|
+
expect(output.length).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should format report as JSON', async () => {
|
|
107
|
+
const report = await runAnalysis({
|
|
108
|
+
projectPath: path.join(tempDir, 'TestApp.xcodeproj'),
|
|
109
|
+
analyzers: ['code'],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const formatter = createFormatter('json');
|
|
113
|
+
const output = formatter.format(report);
|
|
114
|
+
|
|
115
|
+
const parsed = JSON.parse(output);
|
|
116
|
+
expect(parsed).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should format report as HTML', async () => {
|
|
120
|
+
const report = await runAnalysis({
|
|
121
|
+
projectPath: path.join(tempDir, 'TestApp.xcodeproj'),
|
|
122
|
+
analyzers: ['code'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const formatter = createFormatter('html');
|
|
126
|
+
const output = formatter.format(report);
|
|
127
|
+
|
|
128
|
+
expect(output).toContain('<');
|
|
129
|
+
});
|
|
130
|
+
});
|