@rankcli/agent-runtime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +242 -0
  2. package/dist/analyzer-2CSWIQGD.mjs +6 -0
  3. package/dist/chunk-YNZYHEYM.mjs +774 -0
  4. package/dist/index.d.mts +4012 -0
  5. package/dist/index.d.ts +4012 -0
  6. package/dist/index.js +29672 -0
  7. package/dist/index.mjs +28602 -0
  8. package/package.json +53 -0
  9. package/scripts/build-deno.ts +134 -0
  10. package/src/audit/ai/analyzer.ts +347 -0
  11. package/src/audit/ai/index.ts +29 -0
  12. package/src/audit/ai/prompts/content-analysis.ts +271 -0
  13. package/src/audit/ai/types.ts +179 -0
  14. package/src/audit/checks/additional-checks.ts +439 -0
  15. package/src/audit/checks/ai-citation-worthiness.ts +399 -0
  16. package/src/audit/checks/ai-content-structure.ts +325 -0
  17. package/src/audit/checks/ai-readiness.ts +339 -0
  18. package/src/audit/checks/anchor-text.ts +179 -0
  19. package/src/audit/checks/answer-conciseness.ts +322 -0
  20. package/src/audit/checks/asset-minification.ts +270 -0
  21. package/src/audit/checks/bing-optimization.ts +206 -0
  22. package/src/audit/checks/brand-mention-optimization.ts +349 -0
  23. package/src/audit/checks/caching-headers.ts +305 -0
  24. package/src/audit/checks/canonical-advanced.ts +150 -0
  25. package/src/audit/checks/canonical-domain.ts +196 -0
  26. package/src/audit/checks/citation-quality.ts +358 -0
  27. package/src/audit/checks/client-rendering.ts +542 -0
  28. package/src/audit/checks/color-contrast.ts +342 -0
  29. package/src/audit/checks/content-freshness.ts +170 -0
  30. package/src/audit/checks/content-science.ts +589 -0
  31. package/src/audit/checks/conversion-elements.ts +526 -0
  32. package/src/audit/checks/crawlability.ts +220 -0
  33. package/src/audit/checks/directory-listing.ts +172 -0
  34. package/src/audit/checks/dom-analysis.ts +191 -0
  35. package/src/audit/checks/dom-size.ts +246 -0
  36. package/src/audit/checks/duplicate-content.ts +194 -0
  37. package/src/audit/checks/eeat-signals.ts +990 -0
  38. package/src/audit/checks/entity-seo.ts +396 -0
  39. package/src/audit/checks/featured-snippet.ts +473 -0
  40. package/src/audit/checks/freshness-signals.ts +443 -0
  41. package/src/audit/checks/funnel-intent.ts +463 -0
  42. package/src/audit/checks/hreflang.ts +174 -0
  43. package/src/audit/checks/html-compliance.ts +302 -0
  44. package/src/audit/checks/image-dimensions.ts +167 -0
  45. package/src/audit/checks/images.ts +160 -0
  46. package/src/audit/checks/indexnow.ts +275 -0
  47. package/src/audit/checks/interactive-tools.ts +475 -0
  48. package/src/audit/checks/internal-link-graph.ts +436 -0
  49. package/src/audit/checks/keyword-analysis.ts +239 -0
  50. package/src/audit/checks/keyword-cannibalization.ts +385 -0
  51. package/src/audit/checks/keyword-placement.ts +471 -0
  52. package/src/audit/checks/links.ts +203 -0
  53. package/src/audit/checks/llms-txt.ts +224 -0
  54. package/src/audit/checks/local-seo.ts +296 -0
  55. package/src/audit/checks/mobile.ts +167 -0
  56. package/src/audit/checks/modern-images.ts +226 -0
  57. package/src/audit/checks/navboost-signals.ts +395 -0
  58. package/src/audit/checks/on-page.ts +209 -0
  59. package/src/audit/checks/page-resources.ts +285 -0
  60. package/src/audit/checks/pagination.ts +180 -0
  61. package/src/audit/checks/performance.ts +153 -0
  62. package/src/audit/checks/platform-presence.ts +580 -0
  63. package/src/audit/checks/redirect-analysis.ts +153 -0
  64. package/src/audit/checks/redirect-chain.ts +389 -0
  65. package/src/audit/checks/resource-hints.ts +420 -0
  66. package/src/audit/checks/responsive-css.ts +247 -0
  67. package/src/audit/checks/responsive-images.ts +396 -0
  68. package/src/audit/checks/review-ecosystem.ts +415 -0
  69. package/src/audit/checks/robots-validation.ts +373 -0
  70. package/src/audit/checks/security-headers.ts +172 -0
  71. package/src/audit/checks/security.ts +144 -0
  72. package/src/audit/checks/serp-preview.ts +251 -0
  73. package/src/audit/checks/site-maturity.ts +444 -0
  74. package/src/audit/checks/social-meta.test.ts +275 -0
  75. package/src/audit/checks/social-meta.ts +134 -0
  76. package/src/audit/checks/soft-404.ts +151 -0
  77. package/src/audit/checks/structured-data.ts +238 -0
  78. package/src/audit/checks/tech-detection.ts +496 -0
  79. package/src/audit/checks/topical-clusters.ts +435 -0
  80. package/src/audit/checks/tracker-bloat.ts +462 -0
  81. package/src/audit/checks/tracking-verification.test.ts +371 -0
  82. package/src/audit/checks/tracking-verification.ts +636 -0
  83. package/src/audit/checks/url-safety.ts +682 -0
  84. package/src/audit/deno-entry.ts +66 -0
  85. package/src/audit/discovery/index.ts +15 -0
  86. package/src/audit/discovery/link-crawler.ts +232 -0
  87. package/src/audit/discovery/repo-routes.ts +347 -0
  88. package/src/audit/engine.ts +620 -0
  89. package/src/audit/fixes/index.ts +209 -0
  90. package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
  91. package/src/audit/fixes/social-meta-fixes.ts +463 -0
  92. package/src/audit/index.ts +74 -0
  93. package/src/audit/runner.test.ts +299 -0
  94. package/src/audit/runner.ts +130 -0
  95. package/src/audit/types.ts +1953 -0
  96. package/src/content/featured-snippet.ts +367 -0
  97. package/src/content/generator.test.ts +534 -0
  98. package/src/content/generator.ts +501 -0
  99. package/src/content/headline.ts +317 -0
  100. package/src/content/index.ts +62 -0
  101. package/src/content/intent.ts +258 -0
  102. package/src/content/keyword-density.ts +349 -0
  103. package/src/content/readability.ts +262 -0
  104. package/src/executor.ts +336 -0
  105. package/src/fixer.ts +416 -0
  106. package/src/frameworks/detector.test.ts +248 -0
  107. package/src/frameworks/detector.ts +371 -0
  108. package/src/frameworks/index.ts +68 -0
  109. package/src/frameworks/recipes/angular.yaml +171 -0
  110. package/src/frameworks/recipes/astro.yaml +206 -0
  111. package/src/frameworks/recipes/django.yaml +180 -0
  112. package/src/frameworks/recipes/laravel.yaml +137 -0
  113. package/src/frameworks/recipes/nextjs.yaml +268 -0
  114. package/src/frameworks/recipes/nuxt.yaml +175 -0
  115. package/src/frameworks/recipes/rails.yaml +188 -0
  116. package/src/frameworks/recipes/react.yaml +202 -0
  117. package/src/frameworks/recipes/sveltekit.yaml +154 -0
  118. package/src/frameworks/recipes/vue.yaml +137 -0
  119. package/src/frameworks/recipes/wordpress.yaml +209 -0
  120. package/src/frameworks/suggestion-engine.ts +320 -0
  121. package/src/geo/geo-content.test.ts +305 -0
  122. package/src/geo/geo-content.ts +266 -0
  123. package/src/geo/geo-history.test.ts +473 -0
  124. package/src/geo/geo-history.ts +433 -0
  125. package/src/geo/geo-tracker.test.ts +359 -0
  126. package/src/geo/geo-tracker.ts +411 -0
  127. package/src/geo/index.ts +10 -0
  128. package/src/git/commit-helper.test.ts +261 -0
  129. package/src/git/commit-helper.ts +329 -0
  130. package/src/git/index.ts +12 -0
  131. package/src/git/pr-helper.test.ts +284 -0
  132. package/src/git/pr-helper.ts +307 -0
  133. package/src/index.ts +66 -0
  134. package/src/keywords/ai-keyword-engine.ts +1062 -0
  135. package/src/keywords/ai-summarizer.ts +387 -0
  136. package/src/keywords/ci-mode.ts +555 -0
  137. package/src/keywords/engine.ts +359 -0
  138. package/src/keywords/index.ts +151 -0
  139. package/src/keywords/llm-judge.ts +357 -0
  140. package/src/keywords/nlp-analysis.ts +706 -0
  141. package/src/keywords/prioritizer.ts +295 -0
  142. package/src/keywords/site-crawler.ts +342 -0
  143. package/src/keywords/sources/autocomplete.ts +139 -0
  144. package/src/keywords/sources/competitive-search.ts +450 -0
  145. package/src/keywords/sources/competitor-analysis.ts +374 -0
  146. package/src/keywords/sources/dataforseo.ts +206 -0
  147. package/src/keywords/sources/free-sources.ts +294 -0
  148. package/src/keywords/sources/gsc.ts +123 -0
  149. package/src/keywords/topic-grouping.ts +327 -0
  150. package/src/keywords/types.ts +144 -0
  151. package/src/keywords/wizard.ts +457 -0
  152. package/src/loader.ts +40 -0
  153. package/src/reports/index.ts +7 -0
  154. package/src/reports/report-generator.test.ts +293 -0
  155. package/src/reports/report-generator.ts +713 -0
  156. package/src/scheduler/alerts.test.ts +458 -0
  157. package/src/scheduler/alerts.ts +328 -0
  158. package/src/scheduler/index.ts +8 -0
  159. package/src/scheduler/scheduled-audit.test.ts +377 -0
  160. package/src/scheduler/scheduled-audit.ts +149 -0
  161. package/src/test/integration-test.ts +325 -0
  162. package/src/tools/analyzer.ts +373 -0
  163. package/src/tools/crawl.ts +293 -0
  164. package/src/tools/files.ts +301 -0
  165. package/src/tools/h1-fixer.ts +249 -0
  166. package/src/tools/index.ts +67 -0
  167. package/src/tracking/github-action.ts +326 -0
  168. package/src/tracking/google-analytics.ts +265 -0
  169. package/src/tracking/index.ts +45 -0
  170. package/src/tracking/report-generator.ts +386 -0
  171. package/src/tracking/search-console.ts +335 -0
  172. package/src/types.ts +134 -0
  173. package/src/utils/http.ts +302 -0
  174. package/src/wasm-adapter.ts +297 -0
  175. package/src/wasm-entry.ts +14 -0
  176. package/tsconfig.json +17 -0
  177. package/tsup.wasm.config.ts +26 -0
  178. package/vitest.config.ts +15 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Git Commit Helper
3
+ *
4
+ * Creates conventional commits for SEO fixes with proper co-authoring.
5
+ * Supports GitHub Pages and various static site generators.
6
+ */
7
+
8
+ import { execSync, exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ // System name is configurable (TBD - SEO Autopilot is temporary)
14
+ const SYSTEM_NAME = process.env.SEO_SYSTEM_NAME || 'SEO Autopilot';
15
+ const SYSTEM_EMAIL = process.env.SEO_SYSTEM_EMAIL || 'bot@seo-autopilot.dev';
16
+
17
+ export interface CommitConfig {
18
+ systemName?: string;
19
+ systemEmail?: string;
20
+ dryRun?: boolean;
21
+ }
22
+
23
+ export interface ConventionalCommit {
24
+ type: 'fix' | 'feat' | 'docs' | 'style' | 'refactor' | 'perf' | 'chore';
25
+ scope?: string;
26
+ description: string;
27
+ body?: string;
28
+ breaking?: boolean;
29
+ issues?: string[]; // Issue codes like TITLE_MISSING
30
+ }
31
+
32
+ export interface SEOFixCommit {
33
+ category: string;
34
+ issues: string[];
35
+ filesChanged: string[];
36
+ description: string;
37
+ }
38
+
39
+ /**
40
+ * Maps SEO issue categories to conventional commit types and scopes
41
+ */
42
+ const CATEGORY_MAPPING: Record<string, { type: ConventionalCommit['type']; scope: string }> = {
43
+ 'crawlability': { type: 'fix', scope: 'seo' },
44
+ 'indexability': { type: 'fix', scope: 'seo' },
45
+ 'on-page': { type: 'fix', scope: 'meta' },
46
+ 'content': { type: 'fix', scope: 'content' },
47
+ 'links': { type: 'fix', scope: 'links' },
48
+ 'images': { type: 'fix', scope: 'images' },
49
+ 'structured-data': { type: 'feat', scope: 'schema' },
50
+ 'performance': { type: 'perf', scope: 'web' },
51
+ 'security': { type: 'fix', scope: 'security' },
52
+ 'mobile': { type: 'fix', scope: 'mobile' },
53
+ 'international': { type: 'fix', scope: 'i18n' },
54
+ 'social-meta': { type: 'feat', scope: 'og' },
55
+ };
56
+
57
+ /**
58
+ * Formats a conventional commit message
59
+ */
60
+ export function formatConventionalCommit(commit: ConventionalCommit): string {
61
+ let message = commit.type;
62
+
63
+ if (commit.scope) {
64
+ message += `(${commit.scope})`;
65
+ }
66
+
67
+ if (commit.breaking) {
68
+ message += '!';
69
+ }
70
+
71
+ message += `: ${commit.description}`;
72
+
73
+ if (commit.body) {
74
+ message += `\n\n${commit.body}`;
75
+ }
76
+
77
+ if (commit.issues && commit.issues.length > 0) {
78
+ message += `\n\nIssue codes: ${commit.issues.join(', ')}`;
79
+ }
80
+
81
+ return message;
82
+ }
83
+
84
+ /**
85
+ * Formats a commit message for SEO fixes
86
+ */
87
+ export function formatSEOCommitMessage(fix: SEOFixCommit, config?: CommitConfig): string {
88
+ const mapping = CATEGORY_MAPPING[fix.category] || { type: 'fix', scope: 'seo' };
89
+ const systemName = config?.systemName || SYSTEM_NAME;
90
+ const systemEmail = config?.systemEmail || SYSTEM_EMAIL;
91
+
92
+ const commit: ConventionalCommit = {
93
+ type: mapping.type,
94
+ scope: mapping.scope,
95
+ description: fix.description,
96
+ body: `Files changed:\n${fix.filesChanged.map(f => ` - ${f}`).join('\n')}`,
97
+ issues: fix.issues,
98
+ };
99
+
100
+ let message = formatConventionalCommit(commit);
101
+
102
+ // Add co-author trailer
103
+ message += `\n\nCo-Authored-By: ${systemName} <${systemEmail}>`;
104
+
105
+ return message;
106
+ }
107
+
108
+ /**
109
+ * Creates a git commit with the SEO fix
110
+ */
111
+ export async function createSEOCommit(
112
+ fix: SEOFixCommit,
113
+ config?: CommitConfig
114
+ ): Promise<{ success: boolean; hash?: string; error?: string }> {
115
+ const message = formatSEOCommitMessage(fix, config);
116
+
117
+ if (config?.dryRun) {
118
+ console.log('DRY RUN - Would create commit:');
119
+ console.log('---');
120
+ console.log(message);
121
+ console.log('---');
122
+ return { success: true, hash: 'dry-run' };
123
+ }
124
+
125
+ try {
126
+ // Stage the files
127
+ for (const file of fix.filesChanged) {
128
+ await execAsync(`git add "${file}"`);
129
+ }
130
+
131
+ // Create the commit using heredoc for proper message formatting
132
+ const { stdout } = await execAsync(`git commit -m "$(cat <<'EOF'
133
+ ${message}
134
+ EOF
135
+ )"`);
136
+
137
+ // Extract commit hash
138
+ const hashMatch = stdout.match(/\[[\w-]+ ([a-f0-9]+)\]/);
139
+ const hash = hashMatch ? hashMatch[1] : undefined;
140
+
141
+ return { success: true, hash };
142
+ } catch (error) {
143
+ return { success: false, error: (error as Error).message };
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Creates multiple commits, one per category
149
+ */
150
+ export async function createSEOCommits(
151
+ fixes: SEOFixCommit[],
152
+ config?: CommitConfig
153
+ ): Promise<{ commits: Array<{ fix: SEOFixCommit; result: Awaited<ReturnType<typeof createSEOCommit>> }> }> {
154
+ const commits = [];
155
+
156
+ for (const fix of fixes) {
157
+ const result = await createSEOCommit(fix, config);
158
+ commits.push({ fix, result });
159
+ }
160
+
161
+ return { commits };
162
+ }
163
+
164
+ /**
165
+ * Checks if the current directory is a git repository
166
+ */
167
+ export function isGitRepo(dir?: string): boolean {
168
+ try {
169
+ const cwd = dir ? { cwd: dir } : undefined;
170
+ execSync('git rev-parse --is-inside-work-tree', { ...cwd, stdio: 'ignore' });
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Initializes a git repository if not already initialized
179
+ */
180
+ export async function ensureGitRepo(dir?: string): Promise<void> {
181
+ if (!isGitRepo(dir)) {
182
+ const cwd = dir ? { cwd: dir } : undefined;
183
+ await execAsync('git init', cwd);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Gets the current git user config
189
+ */
190
+ export function getGitUser(): { name?: string; email?: string } {
191
+ try {
192
+ const name = execSync('git config user.name', { encoding: 'utf8' }).trim();
193
+ const email = execSync('git config user.email', { encoding: 'utf8' }).trim();
194
+ return { name, email };
195
+ } catch {
196
+ return {};
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Detects if this is a GitHub Pages repository
202
+ */
203
+ export function detectGitHubPages(dir: string): {
204
+ isGitHubPages: boolean;
205
+ type?: 'jekyll' | 'static' | 'docs' | 'custom';
206
+ baseUrl?: string;
207
+ } {
208
+ const fs = require('fs');
209
+ const path = require('path');
210
+
211
+ // Check for common GitHub Pages indicators
212
+ const checks = [
213
+ { file: '_config.yml', type: 'jekyll' as const },
214
+ { file: 'docs/index.html', type: 'docs' as const },
215
+ { file: 'CNAME', type: 'custom' as const },
216
+ { file: '.nojekyll', type: 'static' as const },
217
+ { file: 'index.html', type: 'static' as const },
218
+ ];
219
+
220
+ for (const check of checks) {
221
+ const filePath = path.join(dir, check.file);
222
+ if (fs.existsSync(filePath)) {
223
+ // Try to extract base URL from _config.yml
224
+ if (check.file === '_config.yml') {
225
+ try {
226
+ const content = fs.readFileSync(filePath, 'utf8');
227
+ const baseUrlMatch = content.match(/baseurl:\s*["']?([^"'\n]+)/);
228
+ const urlMatch = content.match(/url:\s*["']?([^"'\n]+)/);
229
+ return {
230
+ isGitHubPages: true,
231
+ type: check.type,
232
+ baseUrl: urlMatch ? urlMatch[1] + (baseUrlMatch?.[1] || '') : undefined,
233
+ };
234
+ } catch {
235
+ // Continue
236
+ }
237
+ }
238
+
239
+ // Check for CNAME (custom domain)
240
+ if (check.file === 'CNAME') {
241
+ try {
242
+ const cname = fs.readFileSync(path.join(dir, 'CNAME'), 'utf8').trim();
243
+ return {
244
+ isGitHubPages: true,
245
+ type: check.type,
246
+ baseUrl: `https://${cname}`,
247
+ };
248
+ } catch {
249
+ // Continue
250
+ }
251
+ }
252
+
253
+ return { isGitHubPages: true, type: check.type };
254
+ }
255
+ }
256
+
257
+ // Check if remote is github.io
258
+ try {
259
+ const remote = execSync('git remote get-url origin', { cwd: dir, encoding: 'utf8' }).trim();
260
+ if (remote.includes('github.io') || remote.includes('.github.')) {
261
+ return { isGitHubPages: true, type: 'static' };
262
+ }
263
+ } catch {
264
+ // Not a git repo or no remote
265
+ }
266
+
267
+ return { isGitHubPages: false };
268
+ }
269
+
270
+ /**
271
+ * Generates a summary of commits made
272
+ */
273
+ export function generateCommitSummary(
274
+ commits: Array<{ fix: SEOFixCommit; result: { success: boolean; hash?: string; error?: string } }>
275
+ ): string {
276
+ const successful = commits.filter(c => c.result.success);
277
+ const failed = commits.filter(c => !c.result.success);
278
+
279
+ let summary = `## SEO Fix Commits Summary\n\n`;
280
+ summary += `✅ ${successful.length} commits created\n`;
281
+ if (failed.length > 0) {
282
+ summary += `❌ ${failed.length} commits failed\n`;
283
+ }
284
+ summary += '\n';
285
+
286
+ if (successful.length > 0) {
287
+ summary += `### Commits Created\n\n`;
288
+ for (const { fix, result } of successful) {
289
+ const mapping = CATEGORY_MAPPING[fix.category] || { type: 'fix', scope: 'seo' };
290
+ summary += `- \`${result.hash?.slice(0, 7) || 'N/A'}\` ${mapping.type}(${mapping.scope}): ${fix.description}\n`;
291
+ summary += ` - Files: ${fix.filesChanged.join(', ')}\n`;
292
+ summary += ` - Issues: ${fix.issues.join(', ')}\n`;
293
+ }
294
+ }
295
+
296
+ if (failed.length > 0) {
297
+ summary += `\n### Failed Commits\n\n`;
298
+ for (const { fix, result } of failed) {
299
+ summary += `- ❌ ${fix.description}: ${result.error}\n`;
300
+ }
301
+ }
302
+
303
+ return summary;
304
+ }
305
+
306
+ // Export types for conventional commit categories
307
+ export const COMMIT_TYPES = [
308
+ { type: 'fix', description: 'Bug fix (SEO issue correction)' },
309
+ { type: 'feat', description: 'New feature (schema, OG tags, etc.)' },
310
+ { type: 'docs', description: 'Documentation update' },
311
+ { type: 'style', description: 'Code style changes (formatting)' },
312
+ { type: 'refactor', description: 'Code restructuring' },
313
+ { type: 'perf', description: 'Performance improvement' },
314
+ { type: 'chore', description: 'Maintenance task' },
315
+ ] as const;
316
+
317
+ export const SEO_SCOPES = [
318
+ 'seo', // General SEO
319
+ 'meta', // Meta tags (title, description)
320
+ 'og', // Open Graph / Twitter Cards
321
+ 'schema', // Structured data / JSON-LD
322
+ 'images', // Image optimization
323
+ 'links', // Link fixes
324
+ 'content', // Content optimization
325
+ 'security', // Security headers
326
+ 'mobile', // Mobile optimization
327
+ 'i18n', // Internationalization
328
+ 'perf', // Performance
329
+ ] as const;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Git integration module
3
+ *
4
+ * Provides git operations for SEO fixes including:
5
+ * - Conventional commit formatting
6
+ * - Co-author support
7
+ * - GitHub Pages detection
8
+ * - Pull request generation
9
+ */
10
+
11
+ export * from './commit-helper.js';
12
+ export * from './pr-helper.js';
@@ -0,0 +1,284 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ formatPRTitle,
4
+ formatPRBody,
5
+ generatePRDescription,
6
+ createPullRequest,
7
+ type PRConfig,
8
+ type SEOFixSummary,
9
+ type PRResult,
10
+ } from './pr-helper.js';
11
+
12
+ describe('pr-helper', () => {
13
+ describe('formatPRTitle', () => {
14
+ it('formats title for single category fix', () => {
15
+ const fixes: SEOFixSummary[] = [
16
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 2 },
17
+ ];
18
+ expect(formatPRTitle(fixes)).toBe('fix(meta): SEO improvements - 1 issue fixed');
19
+ });
20
+
21
+ it('formats title for multiple issues in same category', () => {
22
+ const fixes: SEOFixSummary[] = [
23
+ { category: 'on-page', issues: ['TITLE_MISSING', 'META_DESC_MISSING'], filesCount: 2 },
24
+ ];
25
+ expect(formatPRTitle(fixes)).toBe('fix(meta): SEO improvements - 2 issues fixed');
26
+ });
27
+
28
+ it('formats title for multiple categories', () => {
29
+ const fixes: SEOFixSummary[] = [
30
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 1 },
31
+ { category: 'social-meta', issues: ['OG_TITLE_MISSING', 'OG_IMAGE_MISSING'], filesCount: 1 },
32
+ ];
33
+ expect(formatPRTitle(fixes)).toBe('fix(seo): SEO improvements - 3 issues fixed');
34
+ });
35
+
36
+ it('handles empty fixes array', () => {
37
+ expect(formatPRTitle([])).toBe('fix(seo): SEO audit fixes');
38
+ });
39
+
40
+ it('uses feat for new features like schema', () => {
41
+ const fixes: SEOFixSummary[] = [
42
+ { category: 'structured-data', issues: ['SCHEMA_MISSING'], filesCount: 1 },
43
+ ];
44
+ expect(formatPRTitle(fixes)).toBe('feat(schema): SEO improvements - 1 issue fixed');
45
+ });
46
+ });
47
+
48
+ describe('formatPRBody', () => {
49
+ it('generates body with summary section', () => {
50
+ const fixes: SEOFixSummary[] = [
51
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 2 },
52
+ ];
53
+ const body = formatPRBody(fixes);
54
+
55
+ expect(body).toContain('## Summary');
56
+ expect(body).toContain('1 SEO issue');
57
+ expect(body).toContain('2 files');
58
+ });
59
+
60
+ it('includes issue codes in body', () => {
61
+ const fixes: SEOFixSummary[] = [
62
+ { category: 'on-page', issues: ['TITLE_MISSING', 'META_DESC_MISSING'], filesCount: 2 },
63
+ ];
64
+ const body = formatPRBody(fixes);
65
+
66
+ expect(body).toContain('TITLE_MISSING');
67
+ expect(body).toContain('META_DESC_MISSING');
68
+ });
69
+
70
+ it('includes category breakdown', () => {
71
+ const fixes: SEOFixSummary[] = [
72
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 1 },
73
+ { category: 'social-meta', issues: ['OG_TITLE_MISSING'], filesCount: 1 },
74
+ ];
75
+ const body = formatPRBody(fixes);
76
+
77
+ expect(body).toContain('On-Page');
78
+ expect(body).toContain('Social Meta');
79
+ });
80
+
81
+ it('includes test plan section', () => {
82
+ const fixes: SEOFixSummary[] = [
83
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 1 },
84
+ ];
85
+ const body = formatPRBody(fixes);
86
+
87
+ expect(body).toContain('## Test plan');
88
+ expect(body).toContain('- [ ]');
89
+ });
90
+
91
+ it('includes generated by footer', () => {
92
+ const fixes: SEOFixSummary[] = [];
93
+ const body = formatPRBody(fixes);
94
+
95
+ expect(body).toContain('Generated with');
96
+ expect(body).toContain('SEO Autopilot');
97
+ });
98
+
99
+ it('includes score improvement if provided', () => {
100
+ const fixes: SEOFixSummary[] = [
101
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 1 },
102
+ ];
103
+ const body = formatPRBody(fixes, { scoreBefore: 65, scoreAfter: 82 });
104
+
105
+ expect(body).toContain('65');
106
+ expect(body).toContain('82');
107
+ expect(body).toContain('+17');
108
+ });
109
+ });
110
+
111
+ describe('generatePRDescription', () => {
112
+ it('generates full PR description with title and body', () => {
113
+ const fixes: SEOFixSummary[] = [
114
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 1 },
115
+ ];
116
+ const description = generatePRDescription(fixes);
117
+
118
+ expect(description.title).toBeDefined();
119
+ expect(description.body).toBeDefined();
120
+ expect(description.title).toContain('fix');
121
+ expect(description.body).toContain('## Summary');
122
+ });
123
+
124
+ it('includes labels suggestion', () => {
125
+ const fixes: SEOFixSummary[] = [
126
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 1 },
127
+ ];
128
+ const description = generatePRDescription(fixes);
129
+
130
+ expect(description.labels).toContain('seo');
131
+ expect(description.labels).toContain('automated');
132
+ });
133
+
134
+ it('suggests reviewers if configured', () => {
135
+ const fixes: SEOFixSummary[] = [
136
+ { category: 'on-page', issues: ['TITLE_MISSING'], filesCount: 1 },
137
+ ];
138
+ const config: PRConfig = {
139
+ reviewers: ['alice', 'bob'],
140
+ };
141
+ const description = generatePRDescription(fixes, config);
142
+
143
+ expect(description.reviewers).toEqual(['alice', 'bob']);
144
+ });
145
+ });
146
+
147
+ describe('createPullRequest', () => {
148
+ beforeEach(() => {
149
+ vi.resetAllMocks();
150
+ });
151
+
152
+ it('returns PR URL on success', async () => {
153
+ const mockExec = vi.fn().mockResolvedValue({
154
+ stdout: 'https://github.com/owner/repo/pull/123\n',
155
+ stderr: '',
156
+ });
157
+
158
+ const result = await createPullRequest({
159
+ title: 'Test PR',
160
+ body: 'Test body',
161
+ baseBranch: 'main',
162
+ headBranch: 'seo-fixes',
163
+ }, { execAsync: mockExec });
164
+
165
+ expect(result.success).toBe(true);
166
+ expect(result.url).toBe('https://github.com/owner/repo/pull/123');
167
+ expect(result.number).toBe(123);
168
+ });
169
+
170
+ it('handles gh cli errors', async () => {
171
+ const mockExec = vi.fn().mockRejectedValue(new Error('gh: not logged in'));
172
+
173
+ const result = await createPullRequest({
174
+ title: 'Test PR',
175
+ body: 'Test body',
176
+ baseBranch: 'main',
177
+ headBranch: 'seo-fixes',
178
+ }, { execAsync: mockExec });
179
+
180
+ expect(result.success).toBe(false);
181
+ expect(result.error).toContain('not logged in');
182
+ });
183
+
184
+ it('creates branch if needed', async () => {
185
+ const commands: string[] = [];
186
+ const mockExec = vi.fn().mockImplementation((cmd: string) => {
187
+ commands.push(cmd);
188
+ if (cmd.includes('gh pr create')) {
189
+ return Promise.resolve({ stdout: 'https://github.com/owner/repo/pull/456\n', stderr: '' });
190
+ }
191
+ return Promise.resolve({ stdout: '', stderr: '' });
192
+ });
193
+
194
+ await createPullRequest({
195
+ title: 'Test PR',
196
+ body: 'Test body',
197
+ baseBranch: 'main',
198
+ headBranch: 'seo-fixes',
199
+ createBranch: true,
200
+ }, { execAsync: mockExec });
201
+
202
+ expect(commands.some(c => c.includes('checkout -b'))).toBe(true);
203
+ });
204
+
205
+ it('supports dry run mode', async () => {
206
+ const mockExec = vi.fn();
207
+
208
+ const result = await createPullRequest({
209
+ title: 'Test PR',
210
+ body: 'Test body',
211
+ baseBranch: 'main',
212
+ headBranch: 'seo-fixes',
213
+ dryRun: true,
214
+ }, { execAsync: mockExec });
215
+
216
+ expect(result.success).toBe(true);
217
+ expect(result.dryRun).toBe(true);
218
+ expect(mockExec).not.toHaveBeenCalledWith(expect.stringContaining('gh pr create'));
219
+ });
220
+
221
+ it('includes labels in gh command', async () => {
222
+ let capturedCommand = '';
223
+ const mockExec = vi.fn().mockImplementation((cmd: string) => {
224
+ capturedCommand = cmd;
225
+ return Promise.resolve({ stdout: 'https://github.com/owner/repo/pull/789\n', stderr: '' });
226
+ });
227
+
228
+ await createPullRequest({
229
+ title: 'Test PR',
230
+ body: 'Test body',
231
+ baseBranch: 'main',
232
+ headBranch: 'seo-fixes',
233
+ labels: ['seo', 'automated'],
234
+ }, { execAsync: mockExec });
235
+
236
+ expect(capturedCommand).toContain('--label');
237
+ expect(capturedCommand).toContain('seo');
238
+ });
239
+ });
240
+
241
+ describe('integration scenarios', () => {
242
+ it('generates complete PR for typical SEO audit', () => {
243
+ const fixes: SEOFixSummary[] = [
244
+ {
245
+ category: 'on-page',
246
+ issues: ['TITLE_MISSING', 'META_DESC_MISSING', 'H1_MISSING'],
247
+ filesCount: 5
248
+ },
249
+ {
250
+ category: 'social-meta',
251
+ issues: ['OG_TITLE_MISSING', 'OG_IMAGE_MISSING', 'TWITTER_CARD_MISSING'],
252
+ filesCount: 5
253
+ },
254
+ {
255
+ category: 'crawlability',
256
+ issues: ['ROBOTS_TXT_MISSING', 'SITEMAP_MISSING'],
257
+ filesCount: 2
258
+ },
259
+ ];
260
+
261
+ const description = generatePRDescription(fixes, {
262
+ scoreBefore: 45,
263
+ scoreAfter: 78,
264
+ });
265
+
266
+ expect(description.title).toContain('8 issues fixed');
267
+ expect(description.body).toContain('+33');
268
+ expect(description.body).toContain('ROBOTS_TXT_MISSING');
269
+ expect(description.body).toContain('OG_TITLE_MISSING');
270
+ expect(description.labels).toContain('seo');
271
+ });
272
+
273
+ it('handles single-file schema addition', () => {
274
+ const fixes: SEOFixSummary[] = [
275
+ { category: 'structured-data', issues: ['SCHEMA_MISSING'], filesCount: 1 },
276
+ ];
277
+
278
+ const description = generatePRDescription(fixes);
279
+
280
+ expect(description.title).toContain('feat(schema)');
281
+ expect(description.body).toContain('SCHEMA_MISSING');
282
+ });
283
+ });
284
+ });