Setting the file. One moment.
Test Validate Heroku Migration Report · Heroku To AWS · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 28.3
Clarify
81
Creating Amazon Aurora Db Cluster With Instances
104
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
— line 122
This file
Number 28.49
Position 49 of 58
Type Python
Size 29 KB
Lines 717 scripts/ test_validate_heroku_migration_report.py
Python · 717 lines · 29 KB
12
13
14 def run (html: str , migration_dir: Path | None = None , mode: str | None = None ) -> tuple[ int , str ]:
15 with tempfile.TemporaryDirectory() as tmp:
16 report = Path(tmp) / "migration-report.html"
17 report.write_text(html, encoding = "utf-8" )
18 cmd = [sys.executable, str ( SCRIPT ), str (report)]
19 if migration_dir is not None :
20 cmd += [ "--migration-dir" , str (migration_dir)]
21 if mode is not None :
22 cmd += [ "--mode" , mode]
23 result = subprocess.run(cmd, capture_output = True , text = True ) # nosec B603
24 return result.returncode, result.stdout + result.stderr
25
26
27 # A minimal but complete, compliant Heroku report.
28 GOOD = """<!DOCTYPE html>
29 <html lang="en">
30 <body>
31 <div class="report">
32 <section id="decision-summary"><p class="verdict-headline">Go, with conditions</p></section>
33 <section id="exec-costs"><table><thead><tr><th scope="col">Tier</th></tr></thead><tbody><tr><td>Balanced</td></tr></tbody></table></section>
34 <section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>
35 <section id="next-steps"><ol><li>See MIGRATION_GUIDE.md</li></ol></section>
36 <footer>Generated by Heroku to AWS Migration Advisor — draft for review; verify figures before executive sign-off.</footer>
37 </div>
38 </body>
39 </html>
40 """
41
42
43 def _rec_dir (tmp: str ) -> Path:
44 d = Path(tmp)
45 (d / "estimation-infra.json" ).write_text(
46 json.dumps({ "recommendation" : { "outcome" : "go_conditional" }}), encoding = "utf-8"
47 )
48 return d
49
50
51 def test_good_report_passes () -> None :
52 code, out = run( GOOD )
53 assert code == 0 , out
54 assert "REPORT_OK" in out
55
56
57 def test_missing_cost_optimization_fails () -> None :
58 html = GOOD .replace(
59 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
60 "" ,
61 )
62 code, out = run(html)
63 assert code == 1 , out
64 assert "cost-optimization" in out
65
66
67 def test_missing_draft_footer_fails () -> None :
68 html = GOOD .replace( "draft for review; verify figures before executive sign-off." , "" )
69 code, out = run(html)
70 assert code == 1 , out
71 assert "draft for review" in out.lower()
72
73
74 def test_badge_verdict_pill_fails () -> None :
75 html = GOOD .replace(
76 '<p class="verdict-headline">Go, with conditions</p>' ,
77 '<span class="badge-verdict-go">Go</span>' ,
78 )
79 code, out = run(html)
80 assert code == 1 , out
81 assert "badge-verdict" in out.lower() or "verdict-headline" in out.lower()
82
83
84 def test_th_without_scope_fails () -> None :
85 html = GOOD .replace( '<th scope="col">Tier</th>' , "<th>Tier</th>" )
86 code, out = run(html)
87 assert code == 1 , out
88 assert "scope" in out.lower()
89
90
91 def test_missing_html_lang_fails () -> None :
92 html = GOOD .replace( '<html lang="en">' , "<html>" )
93 code, out = run(html)
94 assert code == 1 , out
95 assert "lang" in out.lower()
96
97
98 def test_verdict_headline_required_when_recommendation_outcome_present () -> None :
99 # No verdict-headline in decision-summary; recommendation.outcome is declared → FAIL.
100 html = GOOD .replace(
101 '<p class="verdict-headline">Go, with conditions</p>' ,
102 "<p>Go, with conditions</p>" ,
103 )
104 with tempfile.TemporaryDirectory() as tmp:
105 code, out = run(html, migration_dir = _rec_dir(tmp))
106 assert code == 1 , out
107 assert "verdict-headline" in out.lower()
108
109
110 def test_verdict_headline_not_required_without_recommendation () -> None :
111 # Same HTML, but no estimation-infra.json recommendation → the headline
112 # requirement does not fire (the plain-text verdict is allowed).
113 html = GOOD .replace(
114 '<p class="verdict-headline">Go, with conditions</p>' ,
115 "<p>Go, with conditions</p>" ,
116 )
117 code, out = run(html)
118 assert code == 0 , out
119 assert "REPORT_OK" in out
120
121
122 def test_what_if_required_when_two_scenarios () -> None :
123 with tempfile.TemporaryDirectory() as tmp:
124 d = Path(tmp)
125 (d / "scenarios" ).mkdir()
126 (d / "scenarios" / "index.json" ).write_text(
127 json.dumps({ "scenarios" : [{ "id" : "a" }, { "id" : "b" }]}), encoding = "utf-8"
128 )
129 code, out = run( GOOD , migration_dir = d)
130 assert code == 1 , out
131 assert "what-if-scenarios" in out
132
133
134 def test_good_report_with_recommendation_passes () -> None :
135 # Happy path WITH a declared outcome: the verdict-headline is present, so the
136 # recommendation-gated requirement is satisfied and the report passes.
137 with tempfile.TemporaryDirectory() as tmp:
138 code, out = run( GOOD , migration_dir = _rec_dir(tmp))
139 assert code == 0 , out
140 assert "REPORT_OK" in out
141
142
143 def test_empty_cost_optimization_fails () -> None :
144 html = GOOD .replace(
145 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
146 '<section id="cost-optimization"></section>' ,
147 )
148 code, out = run(html)
149 assert code == 1 , out
150 assert "cost-optimization" in out and "empty" in out.lower()
151
152
153 def test_no_eligible_sentence_with_entity_hyphens_passes () -> None :
154 # Regression (09-22 P2): the no-commitment sentence written with entity-escaped
155 # hyphens (`1-year`) renders as the exact required text — must satisfy the
156 # content check (decoded rendered text, not a raw literal match).
157 html = GOOD .replace(
158 "No 1-year/3-year commitment product applies to this architecture." ,
159 "No 1-year/3-year commitment product applies to this architecture." ,
160 )
161 code, out = run(html)
162 assert code == 0 , out
163
164
165 def test_no_eligible_sentence_with_nbsp_passes () -> None :
166 # Regression (09-22 P2): ` ` between words decodes to whitespace; the
167 # sentence must still be recognized after whitespace normalization.
168 html = GOOD .replace(
169 "No 1-year/3-year commitment product applies to this architecture." ,
170 "No 1-year/3-year commitment product applies to this architecture." ,
171 )
172 code, out = run(html)
173 assert code == 0 , out
174
175
176 def test_no_eligible_sentence_only_in_comment_fails () -> None :
177 # Regression (09-22 P2): the sentence present ONLY inside an HTML comment is
178 # not rendered — must NOT satisfy the content requirement.
179 html = GOOD .replace(
180 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
181 '<section id="cost-optimization"><!-- No 1-year/3-year commitment product applies to this architecture. --><p>See optimization notes.</p></section>' ,
182 )
183 code, out = run(html)
184 assert code == 1 , out
185 assert "cost-optimization" in out
186
187
188 def test_no_eligible_sentence_only_in_template_fails () -> None :
189 # Regression (09-22 P2): the sentence present ONLY inside an inert <template>
190 # is not rendered — must NOT satisfy the content requirement.
191 html = GOOD .replace(
192 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
193 '<section id="cost-optimization"><template><p>No 1-year/3-year commitment product applies to this architecture.</p></template><p>See optimization notes.</p></section>' ,
194 )
195 code, out = run(html)
196 assert code == 1 , out
197 assert "cost-optimization" in out
198
199
200 def test_heading_only_cost_optimization_fails () -> None :
201 # A heading alone (no opportunity table, no no-eligible-commitment sentence)
202 # must not satisfy the content requirement.
203 html = GOOD .replace(
204 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
205 '<section id="cost-optimization"><h2>Cost Optimization Opportunities</h2></section>' ,
206 )
207 code, out = run(html)
208 assert code == 1 , out
209 assert "cost-optimization" in out
210
211
212 def test_table_headers_only_cost_optimization_fails () -> None :
213 # A table with column headers but an empty <tbody> — no actual opportunity
214 # rows — must not satisfy the content requirement either.
215 html = GOOD .replace(
216 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
217 '<section id="cost-optimization"><table><thead><tr>'
218 '<th scope="col">Opportunity</th><th scope="col">Savings</th>'
219 "</tr></thead><tbody></tbody></table></section>" ,
220 )
221 code, out = run(html)
222 assert code == 1 , out
223 assert "cost-optimization" in out
224
225
226 def test_cost_optimization_with_real_opportunity_row_passes () -> None :
227 # Control: an actual populated row in the tbody is real content and passes.
228 html = GOOD .replace(
229 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
230 '<section id="cost-optimization"><table><thead><tr>'
231 '<th scope="col">Opportunity</th></tr></thead><tbody><tr>'
232 "<td>1yr Compute Savings Plan: ~25%</td></tr></tbody></table></section>" ,
233 )
234 code, out = run(html)
235 assert code == 0 , out
236 assert "REPORT_OK" in out
237
238
239 def test_empty_tbody_with_credits_disclaimer_fails () -> None :
240 # Regression: an empty <tbody> plus the credits disclaimer prose that normally
241 # accompanies a populated table must NOT satisfy the content check on its own —
242 # the disclaimer is explanatory text, not an opportunity row or the explicit
243 # no-eligible-commitment sentence.
244 html = GOOD .replace(
245 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
246 '<section id="cost-optimization"><table><thead><tr>'
247 '<th scope="col">Optimization</th></tr></thead><tbody></tbody></table>'
248 "<p>Activate credits don't cover RI/Savings Plan upfront costs — they apply "
249 "only to the ongoing discounted hourly rate.</p></section>" ,
250 )
251 code, out = run(html)
252 assert code == 1 , out
253 assert "cost-optimization" in out
254
255
256 def test_th_only_row_in_tbody_does_not_count_as_opportunity_row () -> None :
257 # A <tbody> row made only of <th> cells (no <td>) must not count as a
258 # populated opportunity row.
259 html = GOOD .replace(
260 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
261 '<section id="cost-optimization"><table><thead><tr>'
262 '<th scope="col">Opportunity</th></tr></thead><tbody><tr>'
263 '<th scope="row">Totals</th></tr></tbody></table></section>' ,
264 )
265 code, out = run(html)
266 assert code == 1 , out
267 assert "cost-optimization" in out
268
269
270 def test_nbsp_only_opportunity_cell_fails () -> None :
271 # Regression: a <td> whose only content is a non-breaking-space entity is
272 # visually blank. Raw-source matching sees " " as non-empty literal
273 # text; a parser that decodes character references first correctly treats
274 # it as blank.
275 html = GOOD .replace(
276 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
277 '<section id="cost-optimization"><table><tbody><tr>'
278 "<td> </td></tr></tbody></table></section>" ,
279 )
280 code, out = run(html)
281 assert code == 1 , out
282 assert "cost-optimization" in out
283
284
285 def test_numeric_charref_only_opportunity_cell_fails () -> None :
286 # Same as above but with the numeric character reference form ( )
287 # rather than the named entity.
288 html = GOOD .replace(
289 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
290 '<section id="cost-optimization"><table><tbody><tr>'
291 "<td> </td></tr></tbody></table></section>" ,
292 )
293 code, out = run(html)
294 assert code == 1 , out
295 assert "cost-optimization" in out
296
297
298 def test_commented_out_opportunity_row_fails () -> None :
299 # Regression: a real opportunity row that is commented out in the HTML
300 # source must not count. A regex scanning raw source can match inside an
301 # HTML comment; a parser naturally routes comment text away from
302 # handle_data.
303 html = GOOD .replace(
304 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
305 '<section id="cost-optimization"><table><tbody>'
306 "<!-- <tr><td>RDS Reserved Instance</td></tr> --></tbody></table></section>" ,
307 )
308 code, out = run(html)
309 assert code == 1 , out
310 assert "cost-optimization" in out
311
312
313 def test_opportunity_row_without_explicit_tbody_passes () -> None :
314 # Regression: <table><tr><td>...</td></tr></table> with no <tbody> tag at
315 # all is valid HTML (an implicit tbody is inferred) and a real opportunity
316 # row inside it must still count as content.
317 html = GOOD .replace(
318 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
319 '<section id="cost-optimization"><table><tr>'
320 "<td>RDS Reserved Instance</td></tr></table></section>" ,
321 )
322 code, out = run(html)
323 assert code == 0 , out
324 assert "REPORT_OK" in out
325
326
327 def test_opportunity_row_with_omitted_td_end_tag_passes () -> None :
328 # Regression: the HTML Standard permits a <td> end tag to be omitted
329 # immediately before the parent </tr>. A compact, perfectly valid table
330 # with no </td> at all must still register its real content — the old
331 # parser only finalized a cell on an EXPLICIT handle_endtag("td"), so this
332 # exact shape silently registered as empty and failed a valid report.
333 html = GOOD .replace(
334 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
335 '<section id="cost-optimization"><table><tr>'
336 "<td>RDS Reserved Instance</tr></table></section>" ,
337 )
338 code, out = run(html)
339 assert code == 0 , out
340 assert "REPORT_OK" in out
341
342
343 def test_opportunity_row_with_sibling_omitted_td_end_tags_passes () -> None :
344 # Two sibling cells in the same row, both with omitted end tags — the
345 # second cell's start tag implicitly closes the first. The real
346 # opportunity text in the second cell must still be found.
347 html = GOOD .replace(
348 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
349 '<section id="cost-optimization"><table><tr>'
350 "<td>Opportunity<td>RDS Reserved Instance</tr></table></section>" ,
351 )
352 code, out = run(html)
353 assert code == 0 , out
354 assert "REPORT_OK" in out
355
356
357 def test_opportunity_row_with_omitted_td_end_tag_blank_cell_still_fails () -> None :
358 # Control: an omitted end tag on a genuinely BLANK cell must not
359 # accidentally start passing just because the end tag is optional —
360 # only real, nonblank text should ever satisfy this check.
361 html = GOOD .replace(
362 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
363 '<section id="cost-optimization"><table><tr><td></tr></table></section>' ,
364 )
365 code, out = run(html)
366 assert code == 1 , out
367 assert "cost-optimization" in out
368
369
370 def test_th_scope_with_spaces_around_equals_passes () -> None :
371 # scope = "col" (spaces around =) is valid HTML and must be accepted —
372 # a literal `scope="col"` regex would wrongly reject it.
373 html = GOOD .replace( '<th scope="col">Tier</th>' , '<th scope = "col">Tier</th>' )
374 code, out = run(html)
375 assert code == 0 , out
376 assert "REPORT_OK" in out
377
378
379 def test_th_scope_unquoted_passes () -> None :
380 # scope=col (unquoted attribute value) is valid HTML and must be accepted.
381 html = GOOD .replace( '<th scope="col">Tier</th>' , "<th scope=col>Tier</th>" )
382 code, out = run(html)
383 assert code == 0 , out
384 assert "REPORT_OK" in out
385
386
387 def test_th_scope_single_quoted_passes () -> None :
388 html = GOOD .replace( '<th scope="col">Tier</th>' , "<th scope='col'>Tier</th>" )
389 code, out = run(html)
390 assert code == 0 , out
391 assert "REPORT_OK" in out
392
393
394 def test_html_lang_with_spaces_around_equals_passes () -> None :
395 html = GOOD .replace( '<html lang="en">' , '<html lang = "en">' )
396 code, out = run(html)
397 assert code == 0 , out
398 assert "REPORT_OK" in out
399
400
401 def test_figure_aria_label_unquoted_passes () -> None :
402 html = GOOD .replace(
403 "</div>" ,
404 "<figure aria-label=Costs><figcaption>Costs</figcaption></figure></div>" ,
405 )
406 code, out = run(html)
407 assert code == 0 , out
408 assert "REPORT_OK" in out
409
410
411 def test_figure_without_aria_label_fails () -> None :
412 html = GOOD .replace( "</div>" , "<figure><figcaption>Costs</figcaption></figure></div>" )
413 code, out = run(html)
414 assert code == 1 , out
415 assert "aria-label" in out.lower()
416
417
418 def test_figure_with_aria_label_and_figcaption_passes () -> None :
419 html = GOOD .replace(
420 "</div>" ,
421 '<figure aria-label="Cost breakdown"><figcaption>Costs</figcaption></figure></div>' ,
422 )
423 code, out = run(html)
424 assert code == 0 , out
425 assert "REPORT_OK" in out
426
427
428 def test_verdict_headline_with_spaces_around_equals_passes () -> None :
429 # class = "verdict-headline" (spaces around =) is valid HTML and must be
430 # accepted when recommendation.outcome is declared.
431 html = GOOD .replace(
432 '<p class="verdict-headline">Go, with conditions</p>' ,
433 '<p class = "verdict-headline">Go, with conditions</p>' ,
434 )
435 with tempfile.TemporaryDirectory() as tmp:
436 code, out = run(html, migration_dir = _rec_dir(tmp))
437 assert code == 0 , out
438 assert "REPORT_OK" in out
439
440
441 def test_verdict_headline_unquoted_passes () -> None :
442 # class=verdict-headline (unquoted, single token) is valid HTML and must be
443 # accepted when recommendation.outcome is declared.
444 html = GOOD .replace(
445 '<p class="verdict-headline">Go, with conditions</p>' ,
446 "<p class=verdict-headline>Go, with conditions</p>" ,
447 )
448 with tempfile.TemporaryDirectory() as tmp:
449 code, out = run(html, migration_dir = _rec_dir(tmp))
450 assert code == 0 , out
451 assert "REPORT_OK" in out
452
453
454 def test_badge_verdict_pill_with_spaces_around_equals_fails () -> None :
455 # class = "badge-verdict-go" (spaces around =) is valid, equivalent HTML and
456 # must still be caught by the badge ban — it must not bypass the check by
457 # using different (but legal) attribute formatting.
458 html = GOOD .replace(
459 '<p class="verdict-headline">Go, with conditions</p>' ,
460 '<p class="verdict-headline">Go, with conditions</p>'
461 '<span class = "badge-verdict-go">Go</span>' ,
462 )
463 code, out = run(html)
464 assert code == 1 , out
465 assert "badge-verdict" in out.lower()
466
467
468 def test_badge_verdict_pill_unquoted_fails () -> None :
469 # class=badge-verdict-go (unquoted) must also be caught — regression for the
470 # gap where a badge alongside a normal headline returned REPORT_OK because
471 # the literal `class="..."` regex didn't match unquoted attribute syntax.
472 html = GOOD .replace(
473 '<p class="verdict-headline">Go, with conditions</p>' ,
474 '<p class="verdict-headline">Go, with conditions</p>'
475 "<span class=badge-verdict-go>Go</span>" ,
476 )
477 code, out = run(html)
478 assert code == 1 , out
479 assert "badge-verdict" in out.lower()
480
481
482 def test_figure_without_figcaption_fails () -> None :
483 """A <figure> with aria-label but no <figcaption> fails (symmetric to the aria-label case)."""
484 html = GOOD .replace( "</div>" , '<figure aria-label="Costs">chart</figure></div>' )
485 code, out = run(html)
486 assert code == 1 , out
487 assert "figcaption" in out.lower()
488
489
490 def test_validator_is_packaged_inside_the_skill_directory () -> None :
491 """The validator must live under skills/heroku-to-aws/ so a standalone
492 `npx skills add --skill heroku-to-aws` install (which copies only this
493 skill's own directory tree, not the plugin's top-level scripts/) still
494 carries it. Regression for the packaging gap where the validator lived at
495 the plugin root and Generate's `$PLUGIN_ROOT/scripts/...` invocation had
496 nothing to resolve against in a skill-only install."""
497 assert SCRIPT .is_file()
498 assert SCRIPT .parent.name == "scripts"
499 assert SCRIPT .parent.parent.name == "heroku-to-aws"
500 assert SCRIPT .parent.parent.parent.name == "skills"
501
502
503 # --- Inert/hidden-content negative controls (sweep: close the class across every
504 # rendered-content check, not one parser per review round). Inert = never rendered
505 # by the browser: <script>, <style>, <template>. ---
506
507
508 def test_inert_only_opportunity_table_fails () -> None :
509 """A whole opportunity table nested in a <template> is never rendered, so it
510 must not satisfy the cost-optimization content requirement."""
511 html = GOOD .replace(
512 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
513 '<section id="cost-optimization"><template><table><tr>'
514 "<td>RDS Reserved Instance</td></tr></table></template></section>" ,
515 )
516 code, out = run(html)
517 assert code == 1 , out
518 assert "cost-optimization" in out
519
520
521 def test_opportunity_cell_only_inert_content_fails () -> None :
522 """A visible row whose only cell content is an inert <template>/<script>
523 subtree renders nothing, so it is not a populated opportunity row."""
524 html = GOOD .replace(
525 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
526 '<section id="cost-optimization"><table><tr>'
527 '<td><script type="application/json">{"x":1}</script></td></tr>'
528 "<tr><td><template>RDS Reserved Instance</template></td></tr>"
529 "</table></section>" ,
530 )
531 code, out = run(html)
532 assert code == 1 , out
533 assert "cost-optimization" in out
534
535
536 def test_verdict_headline_only_in_template_fails () -> None :
537 """A verdict-headline that exists only inside an inert <template> renders no
538 verdict, so a declared recommendation.outcome must still fail the check."""
539 html = GOOD .replace(
540 '<p class="verdict-headline">Go, with conditions</p>' ,
541 '<template><p class="verdict-headline">Go, with conditions</p></template>' ,
542 )
543 with tempfile.TemporaryDirectory() as tmp:
544 code, out = run(html, migration_dir = _rec_dir(tmp))
545 assert code == 1 , out
546 assert "verdict-headline" in out.lower()
547
548
549 def test_th_inside_template_not_audited_for_scope () -> None :
550 """A <th> inside an inert <template> is never rendered, so the accessibility
551 scan must not demand scope="col"/"row" on it (no false-positive). The visible
552 exec-costs table keeps its valid scope, so the report still passes."""
553 html = GOOD .replace(
554 "</table></section>" ,
555 "</table>"
556 "<template><table><tr><th>Hidden header, no scope</th></tr></table></template>"
557 "</section>" ,
558 1 ,
559 )
560 code, out = run(html)
561 assert code == 0 , out
562 assert "REPORT_OK" in out
563
564
565 # --- Section-parser regressions (09-22 P2): section identity/counts come from the
566 # stdlib parser, so comments and inert (script/style/template) subtrees never satisfy
567 # a section gate, real quoted/unquoted/spaced ids pass, and data-id is not the id. ---
568
569
570 def test_template_wrapped_section_does_not_satisfy_requirement () -> None :
571 # A required section wrapped entirely in an inert <template> is never rendered,
572 # so it must not count as present.
573 html = GOOD .replace(
574 '<section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section>' ,
575 '<template><section id="cost-optimization"><p>No 1-year/3-year commitment product applies to this architecture.</p></section></template>' ,
576 )
577 code, out = run(html)
578 assert code == 1 , out
579 assert "cost-optimization" in out
580
581
582 def test_commented_section_does_not_satisfy_requirement () -> None :
583 # A section that exists only inside an HTML comment is not rendered.
584 html = GOOD .replace(
585 '<section id="next-steps"><ol><li>See MIGRATION_GUIDE.md</li></ol></section>' ,
586 "<!-- <section id= \" next-steps \" ><ol><li>x</li></ol></section> -->" ,
587 )
588 code, out = run(html)
589 assert code == 1 , out
590 assert "next-steps" in out
591
592
593 def test_data_id_is_not_treated_as_section_id () -> None :
594 # data-id must not be read as the section id.
595 html = GOOD .replace(
596 '<section id="next-steps"><ol><li>See MIGRATION_GUIDE.md</li></ol></section>' ,
597 '<section data-id="next-steps"><ol><li>x</li></ol></section>' ,
598 )
599 code, out = run(html)
600 assert code == 1 , out
601 assert "next-steps" in out
602
603
604 def test_unquoted_and_spaced_section_ids_are_accepted () -> None :
605 # Equivalent HTML in different valid spellings must count the same.
606 html = GOOD .replace( '<section id="next-steps">' , "<section id = next-steps>" )
607 code, out = run(html)
608 assert code == 0 , out
609 assert "REPORT_OK" in out
610
611
612 def test_commented_duplicate_does_not_inflate_count () -> None :
613 # A commented copy of a required section must not trigger a duplicate error.
614 html = GOOD .replace(
615 '<section id="exec-costs"><table><thead><tr><th scope="col">Tier</th></tr></thead><tbody><tr><td>Balanced</td></tr></tbody></table></section>' ,
616 '<section id="exec-costs"><table><thead><tr><th scope="col">Tier</th></tr></thead><tbody><tr><td>Balanced</td></tr></tbody></table></section>'
617 '<!-- <section id="exec-costs">dup</section> -->' ,
618 )
619 code, out = run(html)
620 assert code == 0 , out
621 assert "REPORT_OK" in out
622
623
624 def test_verdict_headline_only_in_template_fails_via_section_parser () -> None :
625 # A decision-summary whose entire content (verdict headline) lives inside a
626 # <template> renders no verdict; with a declared recommendation.outcome it must fail.
627 html = GOOD .replace(
628 '<section id="decision-summary"><p class="verdict-headline">Go, with conditions</p></section>' ,
629 '<template><section id="decision-summary"><p class="verdict-headline">Go, with conditions</p></section></template>'
630 '<section id="decision-summary"><p>Go, with conditions</p></section>' ,
631 )
632 with tempfile.TemporaryDirectory() as tmp:
633 code, out = run(html, migration_dir = _rec_dir(tmp))
634 assert code == 1 , out
635 assert "verdict-headline" in out.lower()
636
637
638 # --- Decision-mode coverage (--mode decision), ported to the co-located suite. ---
639
640 DECISION_GOOD = """<!DOCTYPE html>
641 <html lang="en">
642 <body>
643 <div class="report">
644 <section id="decision-summary"><p class="verdict-headline">Go, with conditions</p></section>
645 <section id="exec-costs"><table><thead><tr><th scope="col">Tier</th></tr></thead><tbody><tr><td>Balanced</td></tr></tbody></table></section>
646 <section id="decision-cta"><p>Ready to execute? Re-run with Execute.</p></section>
647 <footer>Generated by Heroku to AWS Migration Advisor — draft for review; verify figures before executive sign-off.</footer>
648 </div>
649 </body>
650 </html>
651 """
652
653
654 def test_decision_mode_minimal_passes () -> None :
655 code, out = run( DECISION_GOOD , mode = "decision" )
656 assert code == 0 , out
657 assert "REPORT_OK" in out
658
659
660 def test_decision_mode_requires_decision_cta_not_next_steps () -> None :
661 # decision mode requires decision-cta; a next-steps terminal is the wrong mode.
662 html = DECISION_GOOD .replace( "decision-cta" , "next-steps" )
663 code, out = run(html, mode = "decision" )
664 assert code == 1 , out
665 assert "decision-cta" in out
666
667
668 def test_full_mode_must_not_carry_decision_cta () -> None :
669 # The other mode's terminal section must not appear in full mode.
670 html = GOOD .replace(
671 '<section id="next-steps"><ol><li>See MIGRATION_GUIDE.md</li></ol></section>' ,
672 '<section id="next-steps"><ol><li>See MIGRATION_GUIDE.md</li></ol></section>'
673 '<section id="decision-cta"><p>x</p></section>' ,
674 )
675 code, out = run(html)
676 assert code == 1 , out
677 assert "decision-cta" in out
678
679
680 def test_decision_basis_required_when_estimate_declares_it () -> None :
681 # When estimation-infra.json declares recommendation.decision_basis, the report
682 # must render a decision-basis section.
683 import json as _json
684
685 with tempfile.TemporaryDirectory() as tmp:
686 d = Path(tmp)
687 (d / "estimation-infra.json" ).write_text(
688 _json.dumps(
689 { "recommendation" : { "outcome" : "go_conditional" , "decision_basis" : "x" }}
690 ),
691 encoding = "utf-8" ,
692 )
693 code, out = run( GOOD , migration_dir = d)
694 assert code == 1 , out
695 assert "decision-basis" in out
696
697
698 def test_decision_basis_commented_placeholder_does_not_satisfy () -> None :
699 # The shipped skeleton's commented decision-basis placeholder must not count.
700 import json as _json
701
702 html = GOOD .replace(
703 '<section id="next-steps"><ol><li>See MIGRATION_GUIDE.md</li></ol></section>' ,
704 "<!-- <section id= \" decision-basis \" >when Estimate declares it</section> -->"
705 '<section id="next-steps"><ol><li>See MIGRATION_GUIDE.md</li></ol></section>' ,
706 )
707 with tempfile.TemporaryDirectory() as tmp:
708 d = Path(tmp)
709 (d / "estimation-infra.json" ).write_text(
710 _json.dumps(
711 { "recommendation" : { "outcome" : "go_conditional" , "decision_basis" : "x" }}
712 ),
713 encoding = "utf-8" ,
714 )
715 code, out = run(html, migration_dir = d)
716 assert code == 1 , out
717 assert "decision-basis" in out